diff --git a/cmd/openqa-revtui/config.go b/cmd/openqa-revtui/config.go index 02d6542..e971724 100644 --- a/cmd/openqa-revtui/config.go +++ b/cmd/openqa-revtui/config.go @@ -16,6 +16,7 @@ type Group struct { /* Program configuration parameters */ type Config struct { + Name string // Configuration name, if set Instance string // Instance URL to be used RabbitMQ string // RabbitMQ url to be used RabbitMQTopic string // Topic to subscribe to @@ -29,7 +30,12 @@ type Config struct { RequestJobLimit int // Maximum number of jobs in a single request } -var cf Config +func (cf Config) Validate() error { + if len(cf.Groups) == 0 { + return fmt.Errorf("no review groups defined") + } + return nil +} func (cf *Config) LoadToml(filename string) error { if _, err := toml.DecodeFile(filename, cf); err != nil { @@ -54,7 +60,11 @@ func (cf *Config) LoadToml(filename string) error { } cf.Groups[i] = group } - return nil + // Apply filename as name, if no name is set + if cf.Name == "" { + cf.Name = extractFilename(filename) + } + return cf.Validate() } /* Create configuration instance and set default vaules */ diff --git a/cmd/openqa-revtui/openqa-revtui.go b/cmd/openqa-revtui/openqa-revtui.go index c5bb10c..1afa593 100644 --- a/cmd/openqa-revtui/openqa-revtui.go +++ b/cmd/openqa-revtui/openqa-revtui.go @@ -11,42 +11,21 @@ import ( "github.com/os-autoinst/openqa-mon/internal" ) -var knownJobs []gopenqa.Job -var updatedRefresh bool +var tui *TUI -func getKnownJob(id int64) (gopenqa.Job, bool) { - for _, j := range knownJobs { - if j.ID == id { - return j, true - } - } - return gopenqa.Job{}, false -} - -/** Try to update the job with the given status, if present. Returns the found job and true if the job was present */ -func updateJobStatus(status gopenqa.JobStatus) (gopenqa.Job, bool) { - var job gopenqa.Job - for i, j := range knownJobs { - if j.ID == status.ID { - knownJobs[i].State = "done" - knownJobs[i].Result = fmt.Sprintf("%s", status.Result) - return knownJobs[i], true - } - } - return job, false -} - -func loadDefaultConfig() error { +func loadDefaultConfig() (Config, error) { + var cf Config configFile := homeDir() + "/.openqa-revtui.toml" if fileExists(configFile) { if err := cf.LoadToml(configFile); err != nil { - return err + return cf, err } } - return nil + return cf, nil } -func parseProgramArgs() error { +func parseProgramArgs(cf *Config) ([]Config, error) { + cfs := make([]Config, 0) n := len(os.Args) for i := 1; i < n; i++ { arg := os.Args[i] @@ -62,33 +41,35 @@ func parseProgramArgs() error { os.Exit(0) } else if arg == "-c" || arg == "--config" { if i++; i >= n { - return fmt.Errorf("missing argument: %s", "config file") + return cfs, fmt.Errorf("missing argument: %s", "config file") } filename := os.Args[i] + var cf Config if err := cf.LoadToml(filename); err != nil { - return fmt.Errorf("in %s: %s", filename, err) + return cfs, fmt.Errorf("in %s: %s", filename, err) } + cfs = append(cfs, cf) } else if arg == "-r" || arg == "--remote" { if i++; i >= n { - return fmt.Errorf("missing argument: %s", "remote") + return cfs, fmt.Errorf("missing argument: %s", "remote") } cf.Instance = os.Args[i] } else if arg == "-q" || arg == "--rabbit" || arg == "--rabbitmq" { if i++; i >= n { - return fmt.Errorf("missing argument: %s", "RabbitMQ link") + return cfs, fmt.Errorf("missing argument: %s", "RabbitMQ link") } cf.RabbitMQ = os.Args[i] } else if arg == "-i" || arg == "--hide" || arg == "--hide-status" { if i++; i >= n { - return fmt.Errorf("missing argument: %s", "Status to hide") + return cfs, fmt.Errorf("missing argument: %s", "Status to hide") } cf.HideStatus = append(cf.HideStatus, strings.Split(os.Args[i], ",")...) } else if arg == "-p" || arg == "--param" { if i++; i >= n { - return fmt.Errorf("missing argument: %s", "parameter") + return cfs, fmt.Errorf("missing argument: %s", "parameter") } if name, value, err := splitNV(os.Args[i]); err != nil { - return fmt.Errorf("argument parameter is invalid: %s", err) + return cfs, fmt.Errorf("argument parameter is invalid: %s", err) } else { cf.DefaultParams[name] = value } @@ -97,25 +78,27 @@ func parseProgramArgs() error { } else if arg == "-m" || arg == "--mute" || arg == "--silent" || arg == "--no-notify" { cf.Notify = false } else { - return fmt.Errorf("illegal argument: %s", arg) + return cfs, fmt.Errorf("illegal argument: %s", arg) } } else { // Convenience logic. If it contains a = then assume it's a parameter, otherwise assume it's a config file if strings.Contains(arg, "=") { if name, value, err := splitNV(arg); err != nil { - return fmt.Errorf("argument parameter is invalid: %s", err) + return cfs, fmt.Errorf("argument parameter is invalid: %s", err) } else { cf.DefaultParams[name] = value } } else { // Assume it's a config file + var cf Config if err := cf.LoadToml(arg); err != nil { - return fmt.Errorf("in %s: %s", arg, err) + return cfs, fmt.Errorf("in %s: %s", arg, err) } + cfs = append(cfs, cf) } } } - return nil + return cfs, nil } func printUsage() { @@ -135,7 +118,7 @@ func printUsage() { } // Register the given rabbitMQ instance for the tui -func registerRabbitMQ(tui *TUI, remote, topic string) (gopenqa.RabbitMQ, error) { +func registerRabbitMQ(model *TUIModel, remote, topic string) (gopenqa.RabbitMQ, error) { rmq, err := gopenqa.ConnectRabbitMQ(remote) if err != nil { return rmq, fmt.Errorf("RabbitMQ connection error: %s", err) @@ -145,87 +128,103 @@ func registerRabbitMQ(tui *TUI, remote, topic string) (gopenqa.RabbitMQ, error) return rmq, fmt.Errorf("RabbitMQ subscribe error: %s", err) } // Receive function - go func() { + go func(model *TUIModel) { + cf := model.Config for { if status, err := sub.ReceiveJobStatus(); err == nil { now := time.Now() - // Update job, if present - if job, found := updateJobStatus(status); found { - tui.Model.Apply(knownJobs) - tui.SetTracker(fmt.Sprintf("[%s] Job %d-%s:%s %s", now.Format("15:04:05"), job.ID, status.Flavor, status.Build, status.Result)) - tui.Update() - if cf.Notify && !hideJob(job) { - NotifySend(fmt.Sprintf("%s: %s %s", job.JobState(), job.Name, job.Test)) - } - } else { - name := status.Flavor - if status.Build != "" { - name += ":" + status.Build - } - tui.SetTracker(fmt.Sprintf("RabbitMQ: [%s] Foreign job %d-%s %s", now.Format("15:04:05"), job.ID, name, status.Result)) - tui.Update() + // Check if we know this job or if this is just another job. + job := model.Job(status.ID) + if job.ID == 0 { + continue + } + + tui.SetTracker(fmt.Sprintf("[%s] Job %d-%s:%s %s", now.Format("15:04:05"), job.ID, status.Flavor, status.Build, status.Result)) + job.State = "done" + job.Result = fmt.Sprintf("%s", status.Result) + + if cf.Notify && !model.HideJob(*job) { + NotifySend(fmt.Sprintf("%s: %s %s", job.JobState(), job.Name, job.Test)) } } } - }() + }(model) return rmq, err } func main() { - cf = CreateConfig() - if err := loadDefaultConfig(); err != nil { + var defaultConfig Config + var err error + var cfs []Config + + if defaultConfig, err = loadDefaultConfig(); err != nil { fmt.Fprintf(os.Stderr, "Error loading default config file: %s\n", err) os.Exit(1) - } - if err := parseProgramArgs(); err != nil { + if cfs, err = parseProgramArgs(&defaultConfig); err != nil { fmt.Fprintf(os.Stderr, "%s\n", err) os.Exit(1) } - if len(cf.Groups) == 0 { - fmt.Fprintf(os.Stderr, "No review groups defined\n") - os.Exit(1) + // Use default configuration only if no configuration files are loaded. + if len(cfs) < 1 { + if err := defaultConfig.Validate(); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + cfs = append(cfs, defaultConfig) } - instance := gopenqa.CreateInstance(cf.Instance) - instance.SetUserAgent("openqa-mon/revtui") + // Run terminal user interface from all available configuration objects + tui = CreateTUI() + for _, cf := range cfs { + model := tui.CreateTUIModel(&cf) - // Refresh rates below 5 minutes are not allowed on public instances due to the load it puts on them - updatedRefresh = false - if cf.RefreshInterval < 300 { - if strings.Contains(cf.Instance, "://openqa.suse.de") || strings.Contains(cf.Instance, "://openqa.opensuse.org") { - cf.RefreshInterval = 300 - updatedRefresh = true + // Apply sorting of the first group + switch cf.GroupBy { + case "none", "": + model.SetSorting(0) + case "groups", "jobgroups": + model.SetSorting(1) + default: + fmt.Fprintf(os.Stderr, "Unsupported GroupBy: '%s'\n", cf.GroupBy) + os.Exit(1) } } - // Run TUI and use the return code - tui := CreateTUI() - switch cf.GroupBy { - case "none", "": - tui.SetSorting(0) - case "groups", "jobgroups": - tui.SetSorting(1) - default: - fmt.Fprintf(os.Stderr, "Unsupported GroupBy: '%s'\n", cf.GroupBy) - os.Exit(1) - } + // Some settings get applied from the last available configuration + cf := cfs[len(cfs)-1] tui.SetHideStatus(cf.HideStatus) - err := tui_main(tui, &instance) - tui.LeaveAltScreen() // Ensure we leave alt screen + + // Enter main loop + err = tui_main() + tui.LeaveAltScreen() if err != nil { fmt.Fprintf(os.Stderr, "%s\n", err) os.Exit(1) } } -func refreshJobs(tui *TUI, instance *gopenqa.Instance) error { +func RefreshJobs() error { + model := tui.Model() + + // Determine if a job is already known or not + knownJobs := model.jobs + getKnownJob := func(id int64) (gopenqa.Job, bool) { + for _, j := range knownJobs { + if j.ID == id { + return j, true + } + } + return gopenqa.Job{}, false + } + // Get fresh jobs status := tui.Status() - oldJobs := tui.Model.Jobs() + oldJobs := model.Jobs() tui.SetStatus(fmt.Sprintf("Refreshing %d jobs ... ", len(oldJobs))) tui.Update() + // Refresh all jobs at once in one request ids := make([]int64, 0) for _, job := range oldJobs { @@ -235,7 +234,7 @@ func refreshJobs(tui *TUI, instance *gopenqa.Instance) error { tui.SetStatus(fmt.Sprintf("Refreshing %d jobs ... %d%% ", len(oldJobs), 100/n*i)) tui.Update() } - jobs, err := fetchJobsFollow(ids, instance, callback) + jobs, err := fetchJobsFollow(ids, model, callback) if err != nil { return err } @@ -250,9 +249,9 @@ func refreshJobs(tui *TUI, instance *gopenqa.Instance) error { if updated { status = fmt.Sprintf("Last update: [%s] Job %d-%s %s", time.Now().Format("15:04:05"), job.ID, job.Name, job.JobState()) tui.SetStatus(status) - tui.Model.Apply(jobs) + model.Apply(jobs) tui.Update() - if cf.Notify && !hideJob(job) { + if model.Config.Notify && !model.HideJob(job) { NotifySend(fmt.Sprintf("%s: %s %s", job.JobState(), job.Name, job.Test)) } } @@ -260,17 +259,16 @@ func refreshJobs(tui *TUI, instance *gopenqa.Instance) error { // Scan failed jobs for comments state := job.JobState() if state == "failed" || state == "incomplete" || state == "parallel_failed" { - reviewed, err := isReviewed(job, instance, state == "parallel_failed") + reviewed, err := isReviewed(job, model, state == "parallel_failed") if err != nil { return err } - tui.Model.SetReviewed(job.ID, reviewed) + model.SetReviewed(job.ID, reviewed) tui.Update() } } - knownJobs = jobs - tui.Model.Apply(jobs) + model.Apply(jobs) tui.SetStatus(status) tui.Update() return nil @@ -287,41 +285,40 @@ func browserJobs(jobs []gopenqa.Job) error { } // main routine for the TUI instance -func tui_main(tui *TUI, instance *gopenqa.Instance) error { +func tui_main() error { title := "openqa Review TUI Dashboard v" + internal.VERSION - var rabbitmq gopenqa.RabbitMQ + var rabbitmqs []gopenqa.RabbitMQ var err error + rabbitmqs = make([]gopenqa.RabbitMQ, 0) refreshing := false - tui.Keypress = func(key byte) { + tui.Keypress = func(key byte, update *bool) { // Input handling switch key { case 'r': if !refreshing { refreshing = true go func() { - if err := refreshJobs(tui, instance); err != nil { + if err := RefreshJobs(); err != nil { tui.SetStatus(fmt.Sprintf("Error while refreshing: %s", err)) } refreshing = false }() - tui.Update() } case 'u': - tui.Update() + // Pass, update is anyways happening case 'q': tui.done <- true + *update = false case 'h': tui.SetHide(!tui.Hide()) - tui.Model.MoveHome() - tui.Update() + tui.Model().MoveHome() case 'm': tui.SetShowTracker(!tui.showTracker) - tui.Update() case 's': // Shift through the sorting mechanism - tui.SetSorting((tui.Sorting() + 1) % 2) - tui.Update() + model := tui.Model() + model.SetSorting((model.Sorting() + 1) % 2) case 'o', 'O': // Note: 'o' has a failsafe to not open more than 10 links. 'O' overrides this failsafe jobs := tui.GetVisibleJobs() @@ -337,9 +334,6 @@ func tui_main(tui *TUI, instance *gopenqa.Instance) error { tui.SetStatus(fmt.Sprintf("Opened %d links", len(jobs))) } } - tui.Update() - default: - tui.Update() } } tui.EnterAltScreen() @@ -351,81 +345,103 @@ func tui_main(tui *TUI, instance *gopenqa.Instance) error { fmt.Println(title) fmt.Println("") - if updatedRefresh { - fmt.Printf(ANSI_YELLOW + "For OSD and O3 a rate limit of 5 minutes between polling is applied" + ANSI_RESET + "\n\n") - } - fmt.Printf("Initial querying instance %s ... \n", cf.Instance) - fmt.Println("\tGet job groups ... ") - jobgroups, err := FetchJobGroups(instance) - if err != nil { - return fmt.Errorf("error fetching job groups: %s", err) + if len(tui.Tabs) == 0 { + model := &tui.Tabs[0] + cf := model.Config + fmt.Printf("Initial querying instance %s ... \n", cf.Instance) + } else { + fmt.Printf("Initial querying for %d configurations ... \n", len(tui.Tabs)) } - if len(jobgroups) == 0 { - fmt.Fprintf(os.Stderr, "Warn: No job groups\n") - } - tui.Model.SetJobGroups(jobgroups) - fmt.Print("\033[s") // Save cursor position - fmt.Printf("\tGet jobs for %d groups ...", len(cf.Groups)) - jobs, err := FetchJobs(instance, func(group int, groups int, job int, jobs int) { - fmt.Print("\033[u") // Restore cursor position - fmt.Print("\033[K") // Erase till end of line - fmt.Printf("\tGet jobs for %d groups ... %d/%d", len(cf.Groups), group, groups) - if job == 0 { - fmt.Printf(" (%d jobs)", jobs) - } else { - fmt.Printf(" (%d/%d jobs)", job, jobs) + + for i, _ := range tui.Tabs { + model := &tui.Tabs[i] + cf := model.Config + + // Refresh rates below 5 minutes are not allowed on public instances due to the load it puts on them + + if cf.RefreshInterval < 300 { + if strings.Contains(cf.Instance, "://openqa.suse.de") || strings.Contains(cf.Instance, "://openqa.opensuse.org") { + cf.RefreshInterval = 300 + } } - }) - fmt.Println() - if err != nil { - return fmt.Errorf("error fetching jobs: %s", err) - } - if len(jobs) == 0 { - // No reason to continue - there are no jobs to scan - return fmt.Errorf("no jobs found") - } - // Failed jobs will be also scanned for comments - for _, job := range jobs { - state := job.JobState() - if state == "failed" || state == "incomplete" || state == "parallel_failed" { - reviewed, err := isReviewed(job, instance, state == "parallel_failed") + + fmt.Printf("Initial querying instance %s for config %d/%d ... \n", cf.Instance, i+1, len(tui.Tabs)) + model.jobGroups, err = FetchJobGroups(model.Instance) + if err != nil { + return fmt.Errorf("error fetching job groups: %s", err) + } + if len(model.jobGroups) == 0 { + fmt.Fprintf(os.Stderr, "Warn: No job groups\n") + } + fmt.Print("\033[s") // Save cursor position + fmt.Printf("\tGet jobs for %d groups ...", len(cf.Groups)) + jobs, err := FetchJobs(model, func(group int, groups int, job int, jobs int) { + fmt.Print("\033[u") // Restore cursor position + fmt.Print("\033[K") // Erase till end of line + fmt.Printf("\tGet jobs for %d groups ... %d/%d", len(cf.Groups), group, groups) + if job == 0 { + fmt.Printf(" (%d jobs)", jobs) + } else { + fmt.Printf(" (%d/%d jobs)", job, jobs) + } + }) + model.Apply(jobs) + fmt.Println() + if err != nil { + return fmt.Errorf("error fetching jobs: %s", err) + } + if len(jobs) == 0 { + // No reason to continue - there are no jobs to scan + return fmt.Errorf("no jobs found") + } + // Failed jobs will be also scanned for comments + for _, job := range model.jobs { + state := job.JobState() + if state == "failed" || state == "incomplete" || state == "parallel_failed" { + reviewed, err := isReviewed(job, model, state == "parallel_failed") + if err != nil { + return fmt.Errorf("error fetching job comment: %s", err) + } + model.SetReviewed(job.ID, reviewed) + } + } + + // Register RabbitMQ + if cf.RabbitMQ != "" { + rabbitmq, err := registerRabbitMQ(model, cf.RabbitMQ, cf.RabbitMQTopic) if err != nil { - return fmt.Errorf("error fetching job comment: %s", err) + fmt.Fprintf(os.Stderr, "Error establishing link to RabbitMQ %s: %s\n", rabbitRemote(cf.RabbitMQ), err) } - tui.Model.SetReviewed(job.ID, reviewed) + rabbitmqs = append(rabbitmqs, rabbitmq) } } - knownJobs = jobs - tui.Model.Apply(knownJobs) fmt.Println("Initial fetching completed. Entering main loop ... ") tui.Start() tui.Update() - // Register RabbitMQ - if cf.RabbitMQ != "" { - rabbitmq, err = registerRabbitMQ(tui, cf.RabbitMQ, cf.RabbitMQTopic) - if err != nil { - fmt.Fprintf(os.Stderr, "Error establishing link to RabbitMQ %s: %s\n", rabbitRemote(cf.RabbitMQ), err) - } - defer rabbitmq.Close() - } - // Periodic refresh - if cf.RefreshInterval > 0 { - go func() { - for { - time.Sleep(time.Duration(cf.RefreshInterval) * time.Second) - if err := refreshJobs(tui, instance); err != nil { - tui.SetStatus(fmt.Sprintf("Error while refreshing: %s", err)) + for i := range tui.Tabs { + model := &tui.Tabs[i] + interval := model.Config.RefreshInterval + if interval > 0 { + go func(currentTab int) { + for { + time.Sleep(time.Duration(interval) * time.Second) + // Only refresh, if current tab is ours + if tui.currentTab == currentTab { + if err := RefreshJobs(); err != nil { + tui.SetStatus(fmt.Sprintf("Error while refreshing: %s", err)) + } + } } - } - }() + }(i) + } } tui.awaitTerminationSignal() tui.LeaveAltScreen() - if cf.RabbitMQ != "" { - rabbitmq.Close() + for i := range rabbitmqs { + rabbitmqs[i].Close() } return nil } diff --git a/cmd/openqa-revtui/openqa.go b/cmd/openqa-revtui/openqa.go index 4fd9fef..4b9d1c0 100644 --- a/cmd/openqa-revtui/openqa.go +++ b/cmd/openqa-revtui/openqa.go @@ -9,16 +9,6 @@ import ( "github.com/os-autoinst/gopenqa" ) -func hideJob(job gopenqa.Job) bool { - status := job.JobState() - for _, s := range cf.HideStatus { - if status == s { - return true - } - } - return false -} - func isJobTooOld(job gopenqa.Job, maxlifetime int64) bool { if maxlifetime <= 0 { return false @@ -34,8 +24,8 @@ func isJobTooOld(job gopenqa.Job, maxlifetime int64) bool { return deltaT > maxlifetime } -func isReviewed(job gopenqa.Job, instance *gopenqa.Instance, checkParallel bool) (bool, error) { - reviewed, err := checkReviewed(job.ID, instance) +func isReviewed(job gopenqa.Job, model *TUIModel, checkParallel bool) (bool, error) { + reviewed, err := checkReviewed(job.ID, model.Instance) if err != nil || reviewed { return reviewed, err } @@ -43,7 +33,7 @@ func isReviewed(job gopenqa.Job, instance *gopenqa.Instance, checkParallel bool) // If not reviewed but "parallel_failed", check parallel jobs if they are reviewed if checkParallel { for _, childID := range job.Children.Parallel { - reviewed, err := checkReviewed(childID, instance) + reviewed, err := checkReviewed(childID, model.Instance) if err != nil { return reviewed, err } @@ -108,15 +98,15 @@ func FetchJob(id int64, instance *gopenqa.Instance) (gopenqa.Job, error) { } /* Fetch the given jobs and follow their clones */ -func fetchJobsFollow(ids []int64, instance *gopenqa.Instance, progress func(i, n int)) ([]gopenqa.Job, error) { +func fetchJobsFollow(ids []int64, model *TUIModel, progress func(i, n int)) ([]gopenqa.Job, error) { // Obey the maximum number of job per requests. // We split the job ids into multiple requests if necessary jobs := make([]gopenqa.Job, 0) // Progress variables - chunks := len(ids) / cf.RequestJobLimit + chunks := len(ids) / model.Config.RequestJobLimit for i := 0; len(ids) > 0; i++ { // Repeat until no more ids are available. - n := min(cf.RequestJobLimit, len(ids)) - chunk, err := instance.GetJobsFollow(ids[:n]) + n := min(model.Config.RequestJobLimit, len(ids)) + chunk, err := model.Instance.GetJobsFollow(ids[:n]) if progress != nil { progress(i, chunks) } @@ -131,13 +121,17 @@ func fetchJobsFollow(ids []int64, instance *gopenqa.Instance, progress func(i, n } /* Fetch the given jobs from the instance at once */ -func fetchJobs(ids []int64, instance *gopenqa.Instance) ([]gopenqa.Job, error) { +func fetchJobs(ids []int64, model *TUIModel) ([]gopenqa.Job, error) { // Obey the maximum number of job per requests. // We split the job ids into multiple requests if necessary jobs := make([]gopenqa.Job, 0) for len(ids) > 0 { - n := min(cf.RequestJobLimit, len(ids)) - chunk, err := instance.GetJobs(ids[:n]) + n := len(ids) + if model.Config.RequestJobLimit > 0 { + n = min(model.Config.RequestJobLimit, len(ids)) + } + n = max(1, n) + chunk, err := model.Instance.GetJobs(ids[:n]) ids = ids[n:] if err != nil { return jobs, err @@ -148,7 +142,7 @@ func fetchJobs(ids []int64, instance *gopenqa.Instance) ([]gopenqa.Job, error) { // Get cloned jobs, if present for i, job := range jobs { if job.IsCloned() { - if job, err := FetchJob(job.ID, instance); err != nil { + if job, err := FetchJob(job.ID, model.Instance); err != nil { return jobs, err } else { jobs[i] = job @@ -160,18 +154,18 @@ func fetchJobs(ids []int64, instance *gopenqa.Instance) ([]gopenqa.Job, error) { type FetchJobsCallback func(int, int, int, int) -func FetchJobs(instance *gopenqa.Instance, callback FetchJobsCallback) ([]gopenqa.Job, error) { +func FetchJobs(model *TUIModel, callback FetchJobsCallback) ([]gopenqa.Job, error) { ret := make([]gopenqa.Job, 0) - for i, group := range cf.Groups { + for i, group := range model.Config.Groups { params := group.Params - jobs, err := instance.GetOverview("", params) + jobs, err := model.Instance.GetOverview("", params) if err != nil { return ret, err } // Limit jobs to at most MaxJobs - if len(jobs) > cf.MaxJobs { - jobs = jobs[:cf.MaxJobs] + if len(jobs) > model.Config.MaxJobs { + jobs = jobs[:model.Config.MaxJobs] } // Get detailed job instances. Fetch them at once @@ -181,9 +175,9 @@ func FetchJobs(instance *gopenqa.Instance, callback FetchJobsCallback) ([]gopenq } if callback != nil { // Add one to the counter to indicate the progress to humans (0/16 looks weird) - callback(i+1, len(cf.Groups), 0, len(jobs)) + callback(i+1, len(model.Config.Groups), 0, len(jobs)) } - jobs, err = fetchJobs(ids, instance) + jobs, err = fetchJobs(ids, model) if err != nil { return jobs, err } diff --git a/cmd/openqa-revtui/tui.go b/cmd/openqa-revtui/tui.go index b5c3e09..7d8c384 100644 --- a/cmd/openqa-revtui/tui.go +++ b/cmd/openqa-revtui/tui.go @@ -8,7 +8,6 @@ import ( "os/exec" "os/signal" "sort" - "sync" "syscall" "time" "unsafe" @@ -25,6 +24,7 @@ const ANSI_BLUE = "\u001b[34m" const ANSI_MAGENTA = "\u001b[35m" const ANSI_CYAN = "\u001b[36m" const ANSI_WHITE = "\u001b[37m" +const ANSI_BOLD = "\u001b[1m" const ANSI_RESET = "\u001b[0m" const ANSI_ALT_SCREEN = "\x1b[?1049h" @@ -37,15 +37,16 @@ type winsize struct { Ypixel uint16 } -type KeyPressCallback func(byte) +type KeyPressCallback func(byte, *bool) /* Declares the terminal user interface */ type TUI struct { - Model TUIModel - done chan bool + Tabs []TUIModel + done chan bool Keypress KeyPressCallback + currentTab int // Currently selected tab status string // Additional status text tracker string // Additional tracker text for RabbitMQ messages header string // Additional header text @@ -53,7 +54,6 @@ type TUI struct { hide bool // Hide statuses in hideStatus showTracker bool // Show tracker showStatus bool // Show status line - sorting int // Sorting method - 0: none, 1 - by job group screensize int // Lines per screen } @@ -65,25 +65,27 @@ func CreateTUI() *TUI { tui.hide = true tui.showTracker = false tui.showStatus = true - tui.Model.jobs = make([]gopenqa.Job, 0) - tui.Model.jobGroups = make(map[int]gopenqa.JobGroup, 0) - tui.Model.reviewed = make(map[int64]bool, 0) + tui.Tabs = make([]TUIModel, 0) return &tui } /* The model that will be displayed in the TUI*/ type TUIModel struct { + Instance *gopenqa.Instance // openQA instance for this config + Config *Config // Job group configuration for this model + jobs []gopenqa.Job // Jobs to be displayed jobGroups map[int]gopenqa.JobGroup // Job Groups - mutex sync.Mutex // Access mutex to the model offset int // Line offset for printing printLines int // Lines that would need to be printed, needed for offset handling reviewed map[int64]bool // Indicating if failed jobs are reviewed + sorting int // Sorting method - 0: none, 1 - by job group } func (tui *TUI) GetVisibleJobs() []gopenqa.Job { jobs := make([]gopenqa.Job, 0) - for _, job := range tui.Model.jobs { + model := &tui.Tabs[tui.currentTab] + for _, job := range model.jobs { if !tui.hideJob(job) { jobs = append(jobs, job) } @@ -95,15 +97,21 @@ func (model *TUIModel) SetReviewed(job int64, reviewed bool) { model.reviewed[job] = reviewed } +func (model *TUIModel) HideJob(job gopenqa.Job) bool { + status := job.JobState() + for _, s := range model.Config.HideStatus { + if status == s { + return true + } + } + return false +} + func (tui *TUIModel) MoveHome() { - tui.mutex.Lock() - defer tui.mutex.Unlock() tui.offset = 0 } func (tui *TUIModel) Apply(jobs []gopenqa.Job) { - tui.mutex.Lock() - defer tui.mutex.Unlock() tui.jobs = jobs } @@ -111,10 +119,38 @@ func (model *TUIModel) Jobs() []gopenqa.Job { return model.jobs } +func (model *TUIModel) Job(id int64) *gopenqa.Job { + for i := range model.jobs { + if model.jobs[i].ID == id { + return &model.jobs[i] + } + } + // Return dummy job + job := gopenqa.Job{ID: 0} + return &job +} + func (tui *TUIModel) SetJobGroups(grps map[int]gopenqa.JobGroup) { tui.jobGroups = grps } +func (tui *TUI) NextTab() { + if len(tui.Tabs) > 1 { + tui.currentTab-- + if tui.currentTab < 0 { + tui.currentTab = len(tui.Tabs) - 1 + } + tui.Update() + } +} + +func (tui *TUI) PreviousTab() { + if len(tui.Tabs) > 1 { + tui.currentTab = (tui.currentTab + 1) % len(tui.Tabs) + tui.Update() + } +} + func (tui *TUI) SetHide(hide bool) { tui.hide = hide } @@ -128,30 +164,24 @@ func (tui *TUI) SetHideStatus(st []string) { } // Apply sorting method. 0 = none, 1 = by job group -func (tui *TUI) SetSorting(sorting int) { - tui.Model.mutex.Lock() - defer tui.Model.mutex.Unlock() +func (tui *TUIModel) SetSorting(sorting int) { tui.sorting = sorting } -func (tui *TUI) Sorting() int { +func (tui *TUIModel) Sorting() int { return tui.sorting } func (tui *TUI) SetStatus(status string) { - tui.Model.mutex.Lock() - defer tui.Model.mutex.Unlock() tui.status = status } func (tui *TUI) SetTemporaryStatus(status string, duration int) { - tui.Model.mutex.Lock() old := tui.status tui.status = status - tui.Model.mutex.Unlock() tui.Update() - // Reset status text after waiting for duration. But only, if the status text has not been altered in the meantime + // Reset status text after duration, if the status text has not been altered in the meantime go func(old, status string, duration int) { time.Sleep(time.Duration(duration) * time.Second) if tui.status == status { @@ -166,14 +196,10 @@ func (tui *TUI) Status() string { } func (tui *TUI) SetTracker(tracker string) { - tui.Model.mutex.Lock() - defer tui.Model.mutex.Unlock() tui.tracker = tracker } func (tui *TUI) SetShowTracker(tracker bool) { - tui.Model.mutex.Lock() - defer tui.Model.mutex.Unlock() tui.showTracker = tracker } @@ -183,11 +209,25 @@ func (tui *TUI) ShowTracker() bool { } func (tui *TUI) SetHeader(header string) { - tui.Model.mutex.Lock() - defer tui.Model.mutex.Unlock() tui.header = header } +func (tui *TUI) CreateTUIModel(cf *Config) *TUIModel { + instance := gopenqa.CreateInstance(cf.Instance) + instance.SetUserAgent("openqa-mon/revtui") + tui.Tabs = append(tui.Tabs, TUIModel{Instance: &instance, Config: cf}) + model := &tui.Tabs[len(tui.Tabs)-1] + model.jobGroups = make(map[int]gopenqa.JobGroup) + model.jobs = make([]gopenqa.Job, 0) + model.reviewed = make(map[int64]bool) + return model +} + +// Model returns the currently selected model +func (tui *TUI) Model() *TUIModel { + return &tui.Tabs[tui.currentTab] +} + func (tui *TUI) readInput() { var b []byte = make([]byte, 1) var p = make([]byte, 3) // History, needed for special keys @@ -198,6 +238,8 @@ func (tui *TUI) readInput() { } else if n == 0 { // EOL break } + model := tui.Model() + k := b[0] // Shift history, do it manually for now @@ -207,34 +249,43 @@ func (tui *TUI) readInput() { if p[2] == 27 && p[1] == 91 { switch k { case 65: // arrow up - if tui.Model.offset > 0 { - tui.Model.offset-- - tui.Update() + if model.offset > 0 { + model.offset-- } - case 66: // arrow down - max := max(0, (tui.Model.printLines - tui.screensize)) - if tui.Model.offset < max { - tui.Model.offset++ - tui.Update() + max := max(0, (model.printLines - tui.screensize)) + if model.offset < max { + model.offset++ } case 72: // home - tui.Model.offset = 0 + model.offset = 0 case 70: // end - tui.Model.offset = max(0, (tui.Model.printLines - tui.screensize)) + model.offset = max(0, (model.printLines - tui.screensize)) case 53: // page up // Always leave one line overlap for better orientation - tui.Model.offset = max(0, tui.Model.offset-tui.screensize+1) + model.offset = max(0, model.offset-tui.screensize+1) case 54: // page down - max := max(0, (tui.Model.printLines - tui.screensize)) + max := max(0, (model.printLines - tui.screensize)) // Always leave one line overlap for better orientation - tui.Model.offset = min(max, tui.Model.offset+tui.screensize-1) + model.offset = min(max, model.offset+tui.screensize-1) + case 90: // Shift+Tab + tui.PreviousTab() } } + // Default keys + if k == 9 { // Tab + tui.NextTab() + } + + update := true // Forward keypress to listener if tui.Keypress != nil { - tui.Keypress(k) + tui.Keypress(k, &update) + } + + if update { + tui.Update() } } } @@ -285,6 +336,7 @@ func (tui *TUI) hideJob(job gopenqa.Job) bool { return false } state := job.JobState() + model := &tui.Tabs[tui.currentTab] for _, s := range tui.hideStatus { if state == s { return true @@ -292,7 +344,7 @@ func (tui *TUI) hideJob(job gopenqa.Job) bool { // Special reviewed keyword if s == "reviewed" && (state == "failed" || state == "parallel_failed" || state == "incomplete") { - if reviewed, found := tui.Model.reviewed[job.ID]; found && reviewed { + if reviewed, found := model.reviewed[job.ID]; found && reviewed { return true } } @@ -303,7 +355,8 @@ func (tui *TUI) hideJob(job gopenqa.Job) bool { // print all jobs unsorted func (tui *TUI) buildJobsScreen(width int) []string { lines := make([]string, 0) - for _, job := range tui.Model.jobs { + model := &tui.Tabs[tui.currentTab] + for _, job := range model.jobs { if !tui.hideJob(job) { lines = append(lines, tui.formatJobLine(job, width)) } @@ -340,10 +393,11 @@ func jobGroupHeader(group gopenqa.JobGroup, width int) string { func (tui *TUI) buildJobsScreenByGroup(width int) []string { lines := make([]string, 0) + model := &tui.Tabs[tui.currentTab] // Determine active groups first groups := make(map[int][]gopenqa.Job, 0) - for _, job := range tui.Model.jobs { + for _, job := range model.jobs { // Create item if not existing, then append job if _, ok := groups[job.GroupID]; !ok { groups[job.GroupID] = make([]gopenqa.Job, 0) @@ -360,7 +414,7 @@ func (tui *TUI) buildJobsScreenByGroup(width int) []string { // Now print them sorted by group ID first := true for _, id := range grpIDs { - grp := tui.Model.jobGroups[id] + grp := model.jobGroups[id] jobs := groups[id] statC := make(map[string]int, 0) hidden := 0 @@ -379,7 +433,7 @@ func (tui *TUI) buildJobsScreenByGroup(width int) []string { } // Increase status counter status := job.JobState() - if status == "failed" && tui.Model.reviewed[job.ID] { + if status == "failed" && model.reviewed[job.ID] { status = "reviewed" } if c, exists := statC[status]; exists { @@ -458,6 +512,24 @@ func (tui *TUI) buildHeader(_ int) []string { if tui.header != "" { lines = append(lines, tui.header) lines = append(lines, "q:Quit r:Refresh h:Hide/Show jobs o:Open links m:Toggle RabbitMQ tracker s:Switch sorting Arrows:Move up/down") + // Tabs if multiple configs are present + if len(tui.Tabs) > 1 { + tabs := "" + for i := range tui.Tabs { + enabled := (tui.currentTab == i) + model := &tui.Tabs[i] + cf := model.Config + name := cf.Name + if name == "" { + name = fmt.Sprintf("Config %d", i+1) + } + if enabled { + name = ANSI_BOLD + name + ANSI_RESET + } + tabs += fmt.Sprintf(" [%s]", name) + } + lines = append(lines, tabs) + } } return lines } @@ -494,8 +566,9 @@ func (tui *TUI) buildFooter(width int) []string { // Build the full screen func (tui *TUI) buildScreen(width int) []string { lines := make([]string, 0) + model := tui.Model() - switch tui.sorting { + switch model.sorting { case 1: lines = append(lines, tui.buildJobsScreenByGroup(width)...) default: @@ -503,23 +576,23 @@ func (tui *TUI) buildScreen(width int) []string { } lines = trimEmpty(lines) - tui.Model.printLines = len(lines) + // We only scroll through the screen, so those are the relevant lines + model.printLines = len(lines) + return lines } /* Redraw screen */ func (tui *TUI) Update() { - tui.Model.mutex.Lock() - defer tui.Model.mutex.Unlock() + model := tui.Model() width, height := terminalSize() if width <= 0 || height <= 0 { return } // Check for unreasonable values - if width > 1000 { - width = 1000 - } + width = min(width, 1024) + height = min(height, 1024) // Header and footer are separate. We only scroll through the "screen" screen := tui.buildScreen(width) @@ -551,7 +624,7 @@ func (tui *TUI) Update() { // Print screen screensize := 0 - for elem := tui.Model.offset; remainingLines > 0; remainingLines-- { + for elem := model.offset; remainingLines > 0; remainingLines-- { if elem >= len(screen) { fmt.Println("") // Fill screen with empty lines for alignment } else { @@ -610,6 +683,8 @@ func getDateColorcode(t time.Time) string { } func (tui *TUI) formatJobLine(job gopenqa.Job, width int) string { + model := &tui.Tabs[tui.currentTab] + c1 := ANSI_WHITE // date color tStr := "" // Timestamp string @@ -631,7 +706,7 @@ func (tui *TUI) formatJobLine(job gopenqa.Job, width int) string { } // For failed jobs check if they are reviewed if state == "failed" || state == "incomplete" || state == "parallel_failed" { - if reviewed, found := tui.Model.reviewed[job.ID]; found && reviewed { + if reviewed, found := model.reviewed[job.ID]; found && reviewed { c2 = ANSI_MAGENTA state = "reviewed" } diff --git a/cmd/openqa-revtui/utils.go b/cmd/openqa-revtui/utils.go index a4353f2..29a590b 100644 --- a/cmd/openqa-revtui/utils.go +++ b/cmd/openqa-revtui/utils.go @@ -54,3 +54,11 @@ func rabbitRemote(remote string) string { } return remote } + +func extractFilename(path string) string { + i := strings.LastIndex(path, "/") + if i > 0 && i < len(path)-1 { + return path[i+1:] + } + return path +}