681 lines
15 KiB
Go
681 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"sort"
|
|
"time"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/app"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/data/binding"
|
|
"fyne.io/fyne/v2/dialog"
|
|
"fyne.io/fyne/v2/driver/desktop"
|
|
"fyne.io/fyne/v2/theme"
|
|
"fyne.io/fyne/v2/widget"
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/crypto/ssh/knownhosts"
|
|
)
|
|
|
|
var (
|
|
commitCache = make(map[string]commit)
|
|
)
|
|
|
|
type commit struct {
|
|
hash string
|
|
date time.Time
|
|
message string
|
|
}
|
|
|
|
type xinStatus struct {
|
|
tabs *container.AppTabs
|
|
cards []fyne.CanvasObject
|
|
boundStrings []binding.ExternalString
|
|
boundBools []binding.ExternalBool
|
|
log *widget.TextGrid
|
|
repoCommit commit
|
|
config Config
|
|
upgradeProgress *widget.ProgressBar
|
|
hasReboot bool
|
|
window fyne.Window
|
|
ci *Status
|
|
}
|
|
|
|
type Status struct {
|
|
card *widget.Card
|
|
buttonBox *fyne.Container
|
|
commit commit
|
|
client *ssh.Client
|
|
clientEstablished bool
|
|
upToDate bool
|
|
|
|
ConfigurationRevision string `json:"configurationRevision"`
|
|
NeedsRestart bool `json:"needs_restart"`
|
|
NixosVersion string `json:"nixosVersion"`
|
|
NixpkgsRevision string `json:"nixpkgsRevision"`
|
|
SystemDiff string `json:"system_diff"`
|
|
Host string `json:"host"`
|
|
Port int32 `json:"port"`
|
|
Uname string `json:"uname_a"`
|
|
Uptime string `json:"uptime"`
|
|
}
|
|
|
|
func (s *Status) SshClose() error {
|
|
s.clientEstablished = false
|
|
return s.client.Close()
|
|
}
|
|
|
|
func (s *Status) RunCmd(cmd string, x *xinStatus) error {
|
|
khFile := path.Clean(path.Join(os.Getenv("HOME"), ".ssh/known_hosts"))
|
|
hostKeyCB, err := knownhosts.New(khFile)
|
|
if err != nil {
|
|
return fmt.Errorf("can't parse %q: %q", khFile, err)
|
|
}
|
|
|
|
key, err := os.ReadFile(x.config.PrivKeyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("can't load key %q: %q", x.config.PrivKeyPath, err)
|
|
}
|
|
|
|
signer, err := ssh.ParsePrivateKey(key)
|
|
if err != nil {
|
|
return fmt.Errorf("can't parse key: %q", err)
|
|
}
|
|
|
|
sshConf := &ssh.ClientConfig{
|
|
User: "root",
|
|
HostKeyAlgorithms: []string{"ssh-ed25519"},
|
|
Auth: []ssh.AuthMethod{
|
|
ssh.PublicKeys(signer),
|
|
},
|
|
Timeout: 2 * time.Second,
|
|
HostKeyCallback: hostKeyCB,
|
|
}
|
|
|
|
ds := fmt.Sprintf("%s:%d", s.Host, s.Port)
|
|
|
|
s.client, err = ssh.Dial("tcp", ds, sshConf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
session, err := s.client.NewSession()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer session.Close()
|
|
|
|
_, err = session.Output(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type Config struct {
|
|
Statuses []*Status `json:"statuses"`
|
|
Repo string `json:"repo"`
|
|
PrivKeyPath string `json:"priv_key_path"`
|
|
FlakeRSS string `json:"flake_rss"`
|
|
CIHost string `json:"ci_host"`
|
|
}
|
|
|
|
func (c *commit) getInfo(repo string) error {
|
|
msgCmd := exec.Command("git", "log", "--format=%B", "-n", "1", c.hash)
|
|
msgCmd.Dir = repo
|
|
msg, err := msgCmd.Output()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.message = trim(msg)
|
|
|
|
dateCmd := exec.Command("git", "log", "--format=%ci", c.hash)
|
|
dateCmd.Dir = repo
|
|
d, err := dateCmd.Output()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dateStr := trim(d)
|
|
date, err := time.Parse("2006-01-02 15:04:05 -0700", dateStr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
c.date = date
|
|
|
|
return nil
|
|
}
|
|
|
|
func NewCommit(c string) *commit {
|
|
return &commit{
|
|
hash: c,
|
|
}
|
|
}
|
|
|
|
func trim(b []byte) string {
|
|
head := bytes.Split(b, []byte("\n"))
|
|
if head != nil {
|
|
return string(head[0])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (x *xinStatus) aliveCount() float64 {
|
|
alive := 0
|
|
x.hasReboot = false
|
|
for _, s := range x.config.Statuses {
|
|
if s.clientEstablished {
|
|
alive = alive + 1
|
|
if s.NeedsRestart {
|
|
x.hasReboot = true
|
|
}
|
|
}
|
|
}
|
|
return float64(alive)
|
|
}
|
|
|
|
func (x *xinStatus) uptodateCount() float64 {
|
|
utd := 0
|
|
for _, s := range x.config.Statuses {
|
|
if s.upToDate {
|
|
utd = utd + 1
|
|
}
|
|
}
|
|
return float64(utd)
|
|
}
|
|
|
|
func (x *xinStatus) getCommit(c string) (*commit, error) {
|
|
commit := &commit{
|
|
hash: c,
|
|
}
|
|
if c == "DIRTY" {
|
|
return commit, nil
|
|
}
|
|
|
|
if commit, ok := commitCache[c]; ok {
|
|
return &commit, nil
|
|
} else {
|
|
commit := NewCommit(c)
|
|
err := commit.getInfo(x.config.Repo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
commitCache[c] = *commit
|
|
}
|
|
|
|
return commit, nil
|
|
}
|
|
|
|
func (x *xinStatus) updateRepoInfo() error {
|
|
switch {
|
|
case (x.config.Repo != "" && x.config.FlakeRSS == ""):
|
|
revCmd := exec.Command("git", "rev-parse", "HEAD")
|
|
revCmd.Dir = x.config.Repo
|
|
currentRev, err := revCmd.Output()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
commit, err := x.getCommit(trim(currentRev))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
x.repoCommit = *commit
|
|
default:
|
|
resp := &Feed{}
|
|
res, err := http.Get(x.config.FlakeRSS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if res == nil {
|
|
return fmt.Errorf("invalid response")
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if err = xml.NewDecoder(res.Body).Decode(&resp); err != nil {
|
|
return err
|
|
}
|
|
cmit, err := resp.LatestHash()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
x.repoCommit = *cmit
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (x *xinStatus) updateHostInfo() error {
|
|
khFile := path.Clean(path.Join(os.Getenv("HOME"), ".ssh/known_hosts"))
|
|
hostKeyCB, err := knownhosts.New(khFile)
|
|
if err != nil {
|
|
return fmt.Errorf("can't parse %q: %q", khFile, err)
|
|
}
|
|
|
|
key, err := os.ReadFile(x.config.PrivKeyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("can't load key %q: %q", x.config.PrivKeyPath, err)
|
|
}
|
|
|
|
signer, err := ssh.ParsePrivateKey(key)
|
|
if err != nil {
|
|
return fmt.Errorf("can't parse key: %q", err)
|
|
}
|
|
|
|
sshConf := &ssh.ClientConfig{
|
|
User: "root",
|
|
HostKeyAlgorithms: []string{"ssh-ed25519"},
|
|
Auth: []ssh.AuthMethod{
|
|
ssh.PublicKeys(signer),
|
|
},
|
|
Timeout: 2 * time.Second,
|
|
HostKeyCallback: hostKeyCB,
|
|
}
|
|
|
|
upToDateCount := len(x.config.Statuses)
|
|
for _, s := range x.config.Statuses {
|
|
s := s
|
|
var err error
|
|
ds := fmt.Sprintf("%s:%d", s.Host, s.Port)
|
|
if !s.clientEstablished {
|
|
s.client, err = ssh.Dial("tcp", ds, sshConf)
|
|
if err != nil {
|
|
s.card.Subtitle = "can't connect"
|
|
upToDateCount = upToDateCount - 1
|
|
x.Log(fmt.Sprintf("can't Dial host %q (%q): %q", s.Host, ds, err))
|
|
s.card.Refresh()
|
|
continue
|
|
}
|
|
s.clientEstablished = true
|
|
|
|
restartButton := widget.NewButton("Reboot", func() {
|
|
go func() {
|
|
cnf := dialog.NewConfirm("Confirmation", fmt.Sprintf("Are you sure you want to reboot %q?", s.Host), func(doit bool) {
|
|
if doit {
|
|
err := s.RunCmd("xin reboot", x)
|
|
if err != nil {
|
|
log.Println(err)
|
|
}
|
|
s.SshClose()
|
|
}
|
|
}, x.window)
|
|
cnf.SetDismissText("Cancel")
|
|
cnf.SetConfirmText("Ok")
|
|
cnf.Show()
|
|
|
|
}()
|
|
})
|
|
|
|
updateButton := widget.NewButton("Update", func() {
|
|
go func() {
|
|
err := s.RunCmd("xin update", x)
|
|
if err != nil {
|
|
log.Println(err)
|
|
}
|
|
s.SshClose()
|
|
}()
|
|
})
|
|
|
|
if len(s.buttonBox.Objects) == 0 {
|
|
s.buttonBox.Add(restartButton)
|
|
s.buttonBox.Add(updateButton)
|
|
|
|
if s.Host == x.config.CIHost {
|
|
x.ci = s
|
|
}
|
|
}
|
|
}
|
|
|
|
session, err := s.client.NewSession()
|
|
if err != nil {
|
|
x.Log(fmt.Sprintf("can't create session: %q", err))
|
|
upToDateCount = upToDateCount - 1
|
|
s.clientEstablished = false
|
|
continue
|
|
}
|
|
defer session.Close()
|
|
|
|
output, err := session.Output("xin status")
|
|
if err != nil {
|
|
x.Log(fmt.Sprintf("can't run command: %q", err))
|
|
upToDateCount = upToDateCount - 1
|
|
session.Close()
|
|
s.SshClose()
|
|
continue
|
|
}
|
|
|
|
err = json.Unmarshal(output, s)
|
|
if err != nil {
|
|
x.Log(err.Error())
|
|
upToDateCount = upToDateCount - 1
|
|
continue
|
|
}
|
|
|
|
if s.ConfigurationRevision != x.repoCommit.hash {
|
|
s.card.Subtitle = fmt.Sprintf("%.8s", s.ConfigurationRevision)
|
|
upToDateCount = upToDateCount - 1
|
|
} else {
|
|
s.card.Subtitle = ""
|
|
}
|
|
s.card.Refresh()
|
|
|
|
commit, err := x.getCommit(s.ConfigurationRevision)
|
|
if err != nil {
|
|
x.Log(err.Error())
|
|
continue
|
|
}
|
|
s.commit = *commit
|
|
|
|
s.upToDate = false
|
|
if s.commit.hash == x.repoCommit.hash {
|
|
s.upToDate = true
|
|
}
|
|
}
|
|
|
|
x.upgradeProgress.SetValue(float64(upToDateCount))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (x *xinStatus) Log(s string) {
|
|
log.Println(s)
|
|
/*
|
|
text := x.log.Text()
|
|
now := time.Now()
|
|
log.Println(s)
|
|
x.log.SetText(strings.Join([]string{
|
|
fmt.Sprintf("%s: %s", now.Format(time.RFC822), s),
|
|
text,
|
|
}, "\n"))
|
|
*/
|
|
}
|
|
|
|
func (c *Config) Load(file string) error {
|
|
data, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return json.Unmarshal(data, &c)
|
|
}
|
|
|
|
func (s *Status) ToTable() *widget.Table {
|
|
t := widget.NewTable(
|
|
// Length
|
|
func() (int, int) {
|
|
return 7, 2
|
|
},
|
|
// CreateCell
|
|
func() fyne.CanvasObject {
|
|
//ct := container.NewScroll(container.NewMax(widget.NewLabel("")))
|
|
ct := container.NewStack(container.NewVScroll(widget.NewLabel("")))
|
|
//ct := container.NewMax(widget.NewLabel(""))
|
|
return ct
|
|
},
|
|
// UpdateCell
|
|
func(i widget.TableCellID, o fyne.CanvasObject) {
|
|
ctnr := o.(*fyne.Container)
|
|
content := ctnr.Objects[0].(*container.Scroll).Content.(*widget.Label)
|
|
if i.Col == 0 {
|
|
switch i.Row {
|
|
case 0:
|
|
content.SetText("NixOS Version")
|
|
case 1:
|
|
content.SetText("NixPkgs Revision")
|
|
case 2:
|
|
content.SetText("Uname")
|
|
case 3:
|
|
content.SetText("Uptime")
|
|
case 4:
|
|
content.SetText("Configuration Revision")
|
|
case 5:
|
|
content.SetText("Restart?")
|
|
case 6:
|
|
content.SetText("System Diff")
|
|
}
|
|
}
|
|
if i.Col == 1 {
|
|
switch i.Row {
|
|
case 0:
|
|
content.SetText(s.NixosVersion)
|
|
case 1:
|
|
content.SetText(s.NixpkgsRevision)
|
|
case 2:
|
|
content.SetText(s.Uname)
|
|
case 3:
|
|
content.SetText(s.Uptime)
|
|
case 4:
|
|
content.SetText(s.ConfigurationRevision)
|
|
case 5:
|
|
str := "No"
|
|
if s.NeedsRestart {
|
|
str = "Yes"
|
|
}
|
|
content.SetText(str)
|
|
case 6:
|
|
text, err := base64.StdEncoding.DecodeString(s.SystemDiff)
|
|
if err != nil {
|
|
fmt.Println("decode error:", err)
|
|
return
|
|
}
|
|
content.SetText(string(text))
|
|
}
|
|
|
|
}
|
|
},
|
|
// OnSelected
|
|
// func (i widget.TableCellID) {}
|
|
// OnUnselected
|
|
// func (i widget.TableCellID) {}
|
|
)
|
|
|
|
t.Refresh()
|
|
|
|
t.SetColumnWidth(0, 300.0)
|
|
t.SetColumnWidth(1, 600.0)
|
|
t.SetRowHeight(6, 600.0)
|
|
|
|
return t
|
|
}
|
|
|
|
func buildCards(stat *xinStatus) fyne.CanvasObject {
|
|
var cards []fyne.CanvasObject
|
|
sort.Slice(stat.config.Statuses, func(i, j int) bool {
|
|
return stat.config.Statuses[i].Host < stat.config.Statuses[j].Host
|
|
})
|
|
for _, s := range stat.config.Statuses {
|
|
// TODO: maybe not needed once loopvar stuff is solid?
|
|
s := s
|
|
commitBStr := binding.BindString(&s.commit.message)
|
|
bsl := widget.NewLabelWithData(commitBStr)
|
|
|
|
verBStr := binding.BindString(&s.NixosVersion)
|
|
bvl := widget.NewLabelWithData(verBStr)
|
|
|
|
uptimeBStr := binding.BindString(&s.Uptime)
|
|
uvl := widget.NewLabelWithData(uptimeBStr)
|
|
|
|
restartBBool := binding.BindBool(&s.NeedsRestart)
|
|
bbl := widget.NewCheckWithData("Reboot", restartBBool)
|
|
bbl.Disable()
|
|
|
|
stat.boundStrings = append(stat.boundStrings, commitBStr)
|
|
stat.boundStrings = append(stat.boundStrings, verBStr)
|
|
stat.boundStrings = append(stat.boundStrings, uptimeBStr)
|
|
stat.boundBools = append(stat.boundBools, restartBBool)
|
|
|
|
buttonHBox := container.NewHBox()
|
|
|
|
card := widget.NewCard(s.Host, "",
|
|
container.NewVBox(
|
|
container.NewHBox(bvl),
|
|
container.NewHBox(uvl),
|
|
container.NewHBox(bbl),
|
|
container.NewHBox(bsl),
|
|
buttonHBox,
|
|
),
|
|
)
|
|
|
|
s.card = card
|
|
s.buttonBox = buttonHBox
|
|
cards = append(cards, card)
|
|
stat.cards = append(stat.cards, card)
|
|
}
|
|
|
|
stat.upgradeProgress = widget.NewProgressBar()
|
|
stat.upgradeProgress.Min = 0
|
|
stat.upgradeProgress.Max = stat.aliveCount()
|
|
stat.upgradeProgress.TextFormatter = func() string {
|
|
return fmt.Sprintf("%.0f of %.0f hosts up-to-date",
|
|
stat.upgradeProgress.Value, stat.upgradeProgress.Max)
|
|
}
|
|
|
|
bsCommitMsg := binding.BindString(&stat.repoCommit.message)
|
|
bsCommitHash := binding.BindString(&stat.repoCommit.hash)
|
|
|
|
stat.boundStrings = append(stat.boundStrings, bsCommitMsg)
|
|
stat.boundStrings = append(stat.boundStrings, bsCommitHash)
|
|
|
|
ciStart := widget.NewButton("CI Start", func() {
|
|
go func() {
|
|
err := stat.ci.RunCmd("xin ci start", stat)
|
|
if err != nil {
|
|
log.Println(err)
|
|
}
|
|
}()
|
|
})
|
|
ciUpdate := widget.NewButton("CI Update", func() {
|
|
go func() {
|
|
err := stat.ci.RunCmd("xin ci update", stat)
|
|
if err != nil {
|
|
log.Println(err)
|
|
}
|
|
}()
|
|
})
|
|
|
|
statusCard := widget.NewCard("Xin Status", "", container.NewVBox(
|
|
widget.NewLabelWithData(bsCommitMsg),
|
|
container.NewHBox(ciStart, ciUpdate),
|
|
stat.upgradeProgress,
|
|
))
|
|
stat.cards = append(cards, statusCard)
|
|
|
|
return container.NewVBox(
|
|
statusCard,
|
|
container.NewGridWithColumns(3, cards...),
|
|
)
|
|
}
|
|
|
|
func main() {
|
|
log.SetPrefix("xintray: ")
|
|
status := &xinStatus{}
|
|
dataPath := path.Clean(path.Join(os.Getenv("HOME"), ".xin.json"))
|
|
err := status.config.Load(dataPath)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
a := app.New()
|
|
a.Settings().SetTheme(&xinTheme{})
|
|
w := a.NewWindow("xintray")
|
|
if w == nil {
|
|
log.Fatalln("unable to create window")
|
|
}
|
|
|
|
status.window = w
|
|
|
|
ctrlQ := &desktop.CustomShortcut{KeyName: fyne.KeyQ, Modifier: fyne.KeyModifierControl}
|
|
ctrlW := &desktop.CustomShortcut{KeyName: fyne.KeyW, Modifier: fyne.KeyModifierControl}
|
|
w.Canvas().AddShortcut(ctrlQ, func(shortcut fyne.Shortcut) {
|
|
a.Quit()
|
|
})
|
|
w.Canvas().AddShortcut(ctrlW, func(shortcut fyne.Shortcut) {
|
|
w.Hide()
|
|
})
|
|
|
|
tabs := container.NewAppTabs(
|
|
container.NewTabItemWithIcon("Status", theme.ComputerIcon(), buildCards(status)),
|
|
)
|
|
|
|
status.tabs = tabs
|
|
status.log = widget.NewTextGrid()
|
|
|
|
err = status.updateRepoInfo()
|
|
if err != nil {
|
|
status.log.SetText(err.Error())
|
|
}
|
|
|
|
go func() {
|
|
for {
|
|
err = status.updateRepoInfo()
|
|
if err != nil {
|
|
status.log.SetText(err.Error())
|
|
}
|
|
|
|
err = status.updateHostInfo()
|
|
if err != nil {
|
|
status.log.SetText(err.Error())
|
|
}
|
|
for _, s := range status.boundStrings {
|
|
s.Reload()
|
|
}
|
|
for _, s := range status.boundBools {
|
|
s.Reload()
|
|
}
|
|
time.Sleep(3 * time.Second)
|
|
status.upgradeProgress.Max = status.aliveCount()
|
|
}
|
|
}()
|
|
|
|
for _, s := range status.config.Statuses {
|
|
tabs.Append(container.NewTabItem(s.Host, s.ToTable()))
|
|
}
|
|
|
|
tabs.SetTabLocation(container.TabLocationLeading)
|
|
|
|
iconImg := buildImage(status)
|
|
a.SetIcon(iconImg)
|
|
|
|
if desk, ok := a.(desktop.App); ok {
|
|
iconImg := buildImage(status)
|
|
m := fyne.NewMenu("xintray",
|
|
fyne.NewMenuItem("Show", func() {
|
|
w.Show()
|
|
}))
|
|
desk.SetSystemTrayMenu(m)
|
|
desk.SetSystemTrayIcon(iconImg)
|
|
a.SetIcon(iconImg)
|
|
go func() {
|
|
for {
|
|
img := buildImage(status)
|
|
desk.SetSystemTrayIcon(img)
|
|
a.SetIcon(img)
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
}()
|
|
}
|
|
|
|
status.log.SetText("starting...")
|
|
|
|
w.SetContent(container.NewAppTabs(
|
|
container.NewTabItem("Hosts", tabs),
|
|
container.NewTabItem("Config", container.NewStack(widget.NewCard("Config", "", nil))),
|
|
container.NewTabItem("Logs", container.NewStack(status.log)),
|
|
))
|
|
w.SetCloseIntercept(func() {
|
|
w.Hide()
|
|
})
|
|
w.ShowAndRun()
|
|
}
|