diff --git a/cmd/harbor/root/cmd.go b/cmd/harbor/root/cmd.go index 039e39ea6..2d5627e88 100644 --- a/cmd/harbor/root/cmd.go +++ b/cmd/harbor/root/cmd.go @@ -23,6 +23,7 @@ import ( "github.com/goharbor/harbor-cli/cmd/harbor/root/context" "github.com/goharbor/harbor-cli/cmd/harbor/root/cve" "github.com/goharbor/harbor-cli/cmd/harbor/root/instance" + "github.com/goharbor/harbor-cli/cmd/harbor/root/jobservice" "github.com/goharbor/harbor-cli/cmd/harbor/root/labels" "github.com/goharbor/harbor-cli/cmd/harbor/root/ldap" "github.com/goharbor/harbor-cli/cmd/harbor/root/project" @@ -203,6 +204,10 @@ harbor help cmd.GroupID = "system" root.AddCommand(cmd) + cmd = jobservice.JobServiceCmd() + cmd.GroupID = "system" + root.AddCommand(cmd) + // Utils cmd = versionCommand() cmd.GroupID = "utils" diff --git a/cmd/harbor/root/jobservice/cmd.go b/cmd/harbor/root/jobservice/cmd.go new file mode 100644 index 000000000..4f238a5a3 --- /dev/null +++ b/cmd/harbor/root/jobservice/cmd.go @@ -0,0 +1,55 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jobservice + +import ( + "github.com/spf13/cobra" +) + +func JobServiceCmd() *cobra.Command { + // jobserviceCmd represents the jobservice command. + var jobserviceCmd = &cobra.Command{ + Use: "jobservice", + Aliases: []string{"js"}, + Short: "Manage Harbor job service", + Long: `Manage Harbor job service, including queues, jobs, worker pools and workers. + +This command provides terminal-based access to the Jobservice dashboard, allowing you to: +- Monitor and manage job queues (pause, resume, clear) +- View running jobs and stop them if necessary +- Inspect worker pools and active workers +- Access job logs with real-time tailing support`, + Example: ` # List all job queues + harbor jobservice queue list + + # Pause a specific job queue + harbor jobservice queue pause IMAGE_SCAN + + # Stop a running job + harbor jobservice job stop + + # Follow job logs in real-time + harbor jobservice job log --follow`, + } + + jobserviceCmd.AddCommand( + QueueCommand(), + JobCommand(), + PoolCommand(), + WorkerCommand(), + ) + + return jobserviceCmd +} diff --git a/cmd/harbor/root/jobservice/job.go b/cmd/harbor/root/jobservice/job.go new file mode 100644 index 000000000..ac459650d --- /dev/null +++ b/cmd/harbor/root/jobservice/job.go @@ -0,0 +1,106 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jobservice + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/base/logviewer" + view "github.com/goharbor/harbor-cli/pkg/views/jobservice" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func JobCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "job", + Short: "Manage individual jobs", + } + + cmd.AddCommand( + StopJobCommand(), + LogJobCommand(), + ) + + return cmd +} + +func StopJobCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop [job-id]", + Short: "Stop a particular job", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var jobID string + if len(args) > 0 { + jobID = args[0] + } else { + log.Debug("No job ID provided, switching to interactive selection...") + var err error + jobID, err = view.SelectRunningJobAsync() + if err != nil { + return err + } + } + + log.Debugf("Attempting to stop job: %s", jobID) + err := api.StopJob(jobID) + if err != nil { + return fmt.Errorf("failed to stop job: %v", utils.ParseHarborErrorMsg(err)) + } + fmt.Printf("Job \"%s\" stopped successfully\n", jobID) + return nil + }, + } + return cmd +} + +func LogJobCommand() *cobra.Command { + var follow bool + var refreshInterval string + + cmd := &cobra.Command{ + Use: "log ", + Short: "Display logs of a particular job", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + jobID := args[0] + + interval := 5 * time.Second + if refreshInterval != "" { + var err error + interval, err = time.ParseDuration(refreshInterval) + if err != nil { + return fmt.Errorf("invalid refresh interval: %w", err) + } + } + + m := logviewer.NewModel(jobID, api.GetJobLog, follow, interval) + if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { + return fmt.Errorf("error running log viewer: %w", err) + } + return nil + }, + } + + cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output") + cmd.Flags().StringVarP(&refreshInterval, "refresh-interval", "n", "", "Interval to refresh logs (default 5s)") + + return cmd +} diff --git a/cmd/harbor/root/jobservice/pool.go b/cmd/harbor/root/jobservice/pool.go new file mode 100644 index 000000000..4cc0aa29f --- /dev/null +++ b/cmd/harbor/root/jobservice/pool.go @@ -0,0 +1,67 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jobservice + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + view "github.com/goharbor/harbor-cli/pkg/views/jobservice" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func PoolCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "pool", + Short: "Manage worker pools", + } + + cmd.AddCommand(ListPoolCommand()) + + return cmd +} + +func ListPoolCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all the worker pools", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + log.Debug("Attempting to list worker pools for formatted output...") + pools, err := api.ListWorkerPools() + if err != nil { + return fmt.Errorf("failed to list worker pools: %v", utils.ParseHarborErrorMsg(err)) + } + log.WithField("output_format", formatFlag).Debug("Output format selected") + err = utils.PrintFormat(pools, formatFlag) + if err != nil { + return err + } + } else { + err := view.ListWorkerPoolsAsync() + if err != nil { + return fmt.Errorf("failed to list worker pools: %w", err) + } + } + return nil + }, + } + return cmd +} diff --git a/cmd/harbor/root/jobservice/queue.go b/cmd/harbor/root/jobservice/queue.go new file mode 100644 index 000000000..207ac886f --- /dev/null +++ b/cmd/harbor/root/jobservice/queue.go @@ -0,0 +1,162 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jobservice + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + view "github.com/goharbor/harbor-cli/pkg/views/jobservice" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func QueueCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "queue", + Short: "Manage job queues", + } + + cmd.AddCommand( + ListQueueCommand(), + ClearQueueCommand(), + PauseQueueCommand(), + ResumeQueueCommand(), + ) + + return cmd +} + +func ListQueueCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List job queues", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + log.Debug("Attempting to list job queues for formatted output...") + queues, err := api.ListJobQueues() + if err != nil { + return fmt.Errorf("failed to list job queues: %v", utils.ParseHarborErrorMsg(err)) + } + log.WithField("output_format", formatFlag).Debug("Output format selected") + err = utils.PrintFormat(queues, formatFlag) + if err != nil { + return err + } + } else { + err := view.ListJobQueuesAsync() + if err != nil { + return fmt.Errorf("failed to list job queues: %w", err) + } + } + return nil + }, + } + return cmd +} + +func ClearQueueCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "clear [job-type]", + Short: "Clear a particular job queue", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var jobType string + if len(args) > 0 { + jobType = args[0] + } else { + log.Debug("No job type provided, switching to interactive selection...") + var err error + jobType, err = view.SelectQueueAsync("Select a Queue to Clear") + if err != nil { + return err + } + } + + log.Debugf("Attempting to clear job queue: %s", jobType) + err := api.ActionPendingJobs(jobType, api.JobActionStop) + if err != nil { + return fmt.Errorf("failed to clear job queue: %v", utils.ParseHarborErrorMsg(err)) + } + fmt.Printf("Pending jobs in jobservice queue \"%s\" cleared successfully\n", jobType) + return nil + }, + } + return cmd +} + +func PauseQueueCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "pause [job-type]", + Short: "Pause a particular job queue", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var jobType string + if len(args) > 0 { + jobType = args[0] + } else { + log.Debug("No job type provided, switching to interactive selection...") + var err error + jobType, err = view.SelectQueueAsync("Select a Queue to Pause") + if err != nil { + return err + } + } + + log.Debugf("Attempting to pause job queue: %s", jobType) + err := api.ActionPendingJobs(jobType, api.JobActionPause) + if err != nil { + return fmt.Errorf("failed to pause job queue: %v", utils.ParseHarborErrorMsg(err)) + } + fmt.Printf("Jobservice queue \"%s\" paused successfully\n", jobType) + return nil + }, + } + return cmd +} + +func ResumeQueueCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "resume [job-type]", + Short: "Resume a particular job queue", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var jobType string + if len(args) > 0 { + jobType = args[0] + } else { + log.Debug("No job type provided, switching to interactive selection...") + var err error + jobType, err = view.SelectQueueAsync("Select a Queue to Resume") + if err != nil { + return err + } + } + + log.Debugf("Attempting to resume job queue: %s", jobType) + err := api.ActionPendingJobs(jobType, api.JobActionResume) + if err != nil { + return fmt.Errorf("failed to resume job queue: %v", utils.ParseHarborErrorMsg(err)) + } + fmt.Printf("Jobservice queue \"%s\" resumed successfully\n", jobType) + return nil + }, + } + return cmd +} diff --git a/cmd/harbor/root/jobservice/worker.go b/cmd/harbor/root/jobservice/worker.go new file mode 100644 index 000000000..8f236b522 --- /dev/null +++ b/cmd/harbor/root/jobservice/worker.go @@ -0,0 +1,85 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jobservice + +import ( + "fmt" + + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + view "github.com/goharbor/harbor-cli/pkg/views/jobservice" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func WorkerCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "worker", + Short: "Manage workers", + } + + cmd.AddCommand(ListWorkerCommand()) + + return cmd +} + +func ListWorkerCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list [pool-id]", + Short: "List workers of a pool", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var poolID string + if len(args) > 0 { + poolID = args[0] + } else { + log.Debug("No pool ID provided, switching to interactive selection...") + var err error + poolID, err = view.SelectPoolAsync("Select a Worker Pool") + if err != nil { + return err + } + } + + if poolID == "all" { + poolID = "" + } + + log.Debugf("Attempting to list workers for pool: %s", poolID) + + formatFlag := viper.GetString("output-format") + if formatFlag != "" { + log.Debug("Attempting to list workers for formatted output...") + workers, err := api.ListWorkers(poolID) + if err != nil { + return fmt.Errorf("failed to list workers: %v", utils.ParseHarborErrorMsg(err)) + } + log.WithField("output_format", formatFlag).Debug("Output format selected") + err = utils.PrintFormat(workers, formatFlag) + if err != nil { + return err + } + } else { + err := view.ListWorkersAsync(poolID) + if err != nil { + return fmt.Errorf("failed to list workers: %w", err) + } + } + return nil + }, + } + return cmd +} diff --git a/pkg/api/jobservice_handler.go b/pkg/api/jobservice_handler.go new file mode 100644 index 000000000..3f65bb6d5 --- /dev/null +++ b/pkg/api/jobservice_handler.go @@ -0,0 +1,121 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package api + +import ( + "github.com/goharbor/go-client/pkg/sdk/v2.0/client/jobservice" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/utils" +) + +type JobAction string + +const ( + JobActionStop JobAction = "stop" + JobActionPause JobAction = "pause" + JobActionResume JobAction = "resume" +) + +func ListJobQueues() ([]*models.JobQueue, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Jobservice.ListJobQueues(ctx, &jobservice.ListJobQueuesParams{}) + if err != nil { + return nil, err + } + + return response.Payload, nil +} + +func ActionPendingJobs(jobType string, action JobAction) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return err + } + + _, err = client.Jobservice.ActionPendingJobs(ctx, &jobservice.ActionPendingJobsParams{ + JobType: jobType, + ActionRequest: &models.ActionRequest{Action: string(action)}, + }) + if err != nil { + return err + } + + return nil +} + +func StopJob(jobID string) error { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return err + } + + _, err = client.Jobservice.StopRunningJob(ctx, &jobservice.StopRunningJobParams{ + JobID: jobID, + }) + if err != nil { + return err + } + + return nil +} + +func GetJobLog(jobID string) (string, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return "", err + } + + response, err := client.Jobservice.ActionGetJobLog(ctx, &jobservice.ActionGetJobLogParams{ + JobID: jobID, + }) + if err != nil { + return "", err + } + + return response.Payload, nil +} + +func ListWorkerPools() ([]*models.WorkerPool, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Jobservice.GetWorkerPools(ctx, &jobservice.GetWorkerPoolsParams{}) + if err != nil { + return nil, err + } + + return response.Payload, nil +} + +func ListWorkers(poolID string) ([]*models.Worker, error) { + ctx, client, err := utils.ContextWithClient() + if err != nil { + return nil, err + } + + response, err := client.Jobservice.GetWorkers(ctx, &jobservice.GetWorkersParams{ + PoolID: poolID, + }) + if err != nil { + return nil, err + } + + return response.Payload, nil +} diff --git a/pkg/views/base/loadingtable/model.go b/pkg/views/base/loadingtable/model.go new file mode 100644 index 000000000..4e150ab58 --- /dev/null +++ b/pkg/views/base/loadingtable/model.go @@ -0,0 +1,116 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package loadingtable + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/goharbor/harbor-cli/pkg/views" + "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" +) + +type FetchMsg struct { + Rows []table.Row + Err error +} + +type Model struct { + spinner spinner.Model + table tablelist.Model + columns []table.Column + fetcher tea.Cmd + title string + loading bool + err error + Aborted bool + Choice table.Row + Selected bool +} + +func NewModel(title string, columns []table.Column, fetcher tea.Cmd) Model { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + + return Model{ + spinner: s, + columns: columns, + fetcher: fetcher, + title: title, + loading: true, + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, m.fetcher) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + m.Aborted = true + return m, tea.Quit + case "enter": + if !m.loading && m.err == nil { + m.Choice = m.table.Table.SelectedRow() + m.Selected = true + return m, tea.Quit + } + } + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case FetchMsg: + m.loading = false + if msg.Err != nil { + m.err = msg.Err + return m, tea.Quit + } + m.table = tablelist.NewModel(m.columns, msg.Rows, len(msg.Rows)) + return m, nil + } + + if !m.loading && m.err == nil { + var cmd tea.Cmd + m.table.Table, cmd = m.table.Table.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m Model) View() string { + titleStr := "" + if m.title != "" { + titleStr = views.TitleStyle.Render(m.title) + "\n" + } + + if m.err != nil { + return titleStr + views.BaseStyle.Render(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(fmt.Sprintf("Error: %v", m.err))) + "\n" + } + if m.loading { + return titleStr + views.BaseStyle.Render(fmt.Sprintf("%s Loading data...", m.spinner.View())) + "\n" + } + return titleStr + m.table.View() +} diff --git a/pkg/views/base/logviewer/model.go b/pkg/views/base/logviewer/model.go new file mode 100644 index 000000000..5b053badf --- /dev/null +++ b/pkg/views/base/logviewer/model.go @@ -0,0 +1,135 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package logviewer + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type LogMsg string +type triggerFetchMsg struct{} + +type Model struct { + viewport viewport.Model + jobID string + fetcher func(string) (string, error) + follow bool + interval time.Duration + lastContent string + ready bool + isFetching bool + err error +} + +func NewModel(jobID string, fetcher func(string) (string, error), follow bool, interval time.Duration) Model { + return Model{ + jobID: jobID, + fetcher: fetcher, + follow: follow, + interval: interval, + } +} + +func (m Model) Init() tea.Cmd { + return m.fetchLogCmd +} + +func (m Model) fetchLogCmd() tea.Msg { + content, err := m.fetcher(m.jobID) + if err != nil { + return err + } + return LogMsg(content) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case tea.KeyMsg: + if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" { + return m, tea.Quit + } + + case tea.WindowSizeMsg: + headerHeight := 2 + footerHeight := 2 + verticalMarginHeight := headerHeight + footerHeight + + if !m.ready { + m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) + m.viewport.YPosition = headerHeight + m.viewport.HighPerformanceRendering = false + m.ready = true + } else { + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - verticalMarginHeight + } + + case LogMsg: + m.isFetching = false + content := string(msg) + if content != m.lastContent { + m.viewport.SetContent(content) + m.lastContent = content + m.viewport.GotoBottom() + } + if m.follow { + return m, tea.Tick(m.interval, func(t time.Time) tea.Msg { + return triggerFetchMsg{} + }) + } + + case triggerFetchMsg: + if !m.isFetching { + m.isFetching = true + return m, m.fetchLogCmd + } + + case error: + m.err = msg + m.isFetching = false + return m, tea.Quit + } + + if m.ready { + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m Model) View() string { + if !m.ready { + return "\n Initializing..." + } + if m.err != nil { + return fmt.Sprintf("\n Error: %v\n", m.err) + } + + header := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf(" Logs for Job: %s", m.jobID)) + footer := lipgloss.NewStyle().Faint(true).Render(fmt.Sprintf(" %.0f%% • q to quit", m.viewport.ScrollPercent()*100)) + + return fmt.Sprintf("%s\n%s\n%s", header, m.viewport.View(), footer) +} diff --git a/pkg/views/jobservice/view.go b/pkg/views/jobservice/view.go new file mode 100644 index 000000000..3841f9518 --- /dev/null +++ b/pkg/views/jobservice/view.go @@ -0,0 +1,266 @@ +// Copyright Project Harbor Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package jobservice + +import ( + "errors" + "fmt" + "strconv" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/goharbor/go-client/pkg/sdk/v2.0/models" + "github.com/goharbor/harbor-cli/pkg/api" + "github.com/goharbor/harbor-cli/pkg/utils" + "github.com/goharbor/harbor-cli/pkg/views/base/loadingtable" + "github.com/goharbor/harbor-cli/pkg/views/base/tablelist" +) + +var ( + queueColumns = []table.Column{ + {Title: "Job Type", Width: tablelist.WidthXXL}, + {Title: "Pending Jobs", Width: tablelist.WidthL}, + {Title: "Latency (s)", Width: tablelist.WidthL}, + {Title: "Paused", Width: tablelist.WidthS}, + } + poolColumns = []table.Column{ + {Title: "Worker Pool ID", Width: tablelist.WidthXXL}, + {Title: "Pid", Width: tablelist.WidthS}, + {Title: "Started At", Width: tablelist.WidthL}, + {Title: "Heartbeat At", Width: tablelist.WidthL}, + {Title: "Concurrency", Width: tablelist.WidthL}, + } + workerColumns = []table.Column{ + {Title: "Worker ID", Width: tablelist.WidthXXL}, + {Title: "Pool ID", Width: tablelist.WidthXXL}, + {Title: "Job Name", Width: tablelist.WidthL}, + {Title: "Job ID", Width: tablelist.WidthXXL}, + {Title: "Started At", Width: tablelist.WidthL}, + {Title: "Checked In At", Width: tablelist.WidthL}, + } +) + +var ErrUserAborted = errors.New("user aborted selection") + +func jobQueueRows(queues []*models.JobQueue) []table.Row { + var rows []table.Row + for _, queue := range queues { + paused := "No" + if queue.Paused { + paused = "Yes" + } + rows = append(rows, table.Row{ + queue.JobType, + strconv.FormatInt(queue.Count, 10), + strconv.FormatInt(queue.Latency, 10), + paused, + }) + } + return rows +} + +func workerPoolRows(pools []*models.WorkerPool) []table.Row { + var rows []table.Row + for _, pool := range pools { + startedAt, _ := utils.FormatCreatedTime(pool.StartAt.String()) + heartbeatAt, _ := utils.FormatCreatedTime(pool.HeartbeatAt.String()) + rows = append(rows, table.Row{ + pool.WorkerPoolID, + strconv.FormatInt(pool.Pid, 10), + startedAt, + heartbeatAt, + strconv.FormatInt(pool.Concurrency, 10), + }) + } + return rows +} + +func workerRows(workers []*models.Worker) []table.Row { + var rows []table.Row + for _, worker := range workers { + startedAt := "" + if worker.StartAt != nil { + startedAt, _ = utils.FormatCreatedTime(worker.StartAt.String()) + } + checkedInAt := "" + if worker.CheckinAt != nil { + checkedInAt, _ = utils.FormatCreatedTime(worker.CheckinAt.String()) + } + rows = append(rows, table.Row{ + worker.ID, + worker.PoolID, + worker.JobName, + worker.JobID, + startedAt, + checkedInAt, + }) + } + return rows +} + +func ListJobQueuesAsync() error { + fetcher := func() tea.Msg { + queues, err := api.ListJobQueues() + if err != nil { + return loadingtable.FetchMsg{Err: err} + } + return loadingtable.FetchMsg{Rows: jobQueueRows(queues)} + } + + m := loadingtable.NewModel("Job Queues", queueColumns, fetcher) + + _, err := tea.NewProgram(m).Run() + return err +} + +func ListWorkerPoolsAsync() error { + fetcher := func() tea.Msg { + pools, err := api.ListWorkerPools() + if err != nil { + return loadingtable.FetchMsg{Err: err} + } + return loadingtable.FetchMsg{Rows: workerPoolRows(pools)} + } + + m := loadingtable.NewModel("Worker Pools", poolColumns, fetcher) + + _, err := tea.NewProgram(m).Run() + return err +} + +func ListWorkersAsync(poolID string) error { + title := "Workers" + if poolID != "" { + title = fmt.Sprintf("Workers in Pool: %s", poolID) + } + + fetcher := func() tea.Msg { + workers, err := api.ListWorkers(poolID) + if err != nil { + return loadingtable.FetchMsg{Err: err} + } + return loadingtable.FetchMsg{Rows: workerRows(workers)} + } + + m := loadingtable.NewModel(title, workerColumns, fetcher) + + _, err := tea.NewProgram(m).Run() + return err +} + +func SelectQueueAsync(title string) (string, error) { + fetcher := func() tea.Msg { + queues, err := api.ListJobQueues() + if err != nil { + return loadingtable.FetchMsg{Err: err} + } + rows := []table.Row{{"all", "", "", ""}} + rows = append(rows, jobQueueRows(queues)...) + return loadingtable.FetchMsg{Rows: rows} + } + + m := loadingtable.NewModel(title, queueColumns, fetcher) + + p, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + return "", err + } + + if model, ok := p.(loadingtable.Model); ok { + if model.Aborted { + return "", ErrUserAborted + } + if !model.Selected { + return "", fmt.Errorf("no queue selected") + } + return model.Choice[0], nil + } + + return "", fmt.Errorf("unexpected selection result") +} + +func SelectPoolAsync(title string) (string, error) { + fetcher := func() tea.Msg { + pools, err := api.ListWorkerPools() + if err != nil { + return loadingtable.FetchMsg{Err: err} + } + rows := []table.Row{{"all", "", "", "", ""}} + rows = append(rows, workerPoolRows(pools)...) + return loadingtable.FetchMsg{Rows: rows} + } + + m := loadingtable.NewModel(title, poolColumns, fetcher) + + p, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + return "", err + } + + if model, ok := p.(loadingtable.Model); ok { + if model.Aborted { + return "", ErrUserAborted + } + if !model.Selected { + return "", fmt.Errorf("no pool selected") + } + return model.Choice[0], nil + } + + return "", fmt.Errorf("unexpected selection result") +} + +func SelectRunningJobAsync() (string, error) { + fetcher := func() tea.Msg { + workers, err := api.ListWorkers("") + if err != nil { + return loadingtable.FetchMsg{Err: err} + } + var rows []table.Row + for _, w := range workers { + if w.JobID != "" { + rows = append(rows, table.Row{w.JobName, w.JobID, w.ID}) + } + } + if len(rows) == 0 { + return loadingtable.FetchMsg{Err: errors.New("no running jobs found")} + } + return loadingtable.FetchMsg{Rows: rows} + } + + columns := []table.Column{ + {Title: "Job Name", Width: tablelist.WidthL}, + {Title: "Job ID", Width: tablelist.WidthXXL}, + {Title: "Worker ID", Width: tablelist.WidthXXL}, + } + + m := loadingtable.NewModel("Select a Running Job", columns, fetcher) + + p, err := tea.NewProgram(m, tea.WithAltScreen()).Run() + if err != nil { + return "", err + } + + if model, ok := p.(loadingtable.Model); ok { + if model.Aborted { + return "", ErrUserAborted + } + if !model.Selected { + return "", fmt.Errorf("no job selected") + } + return model.Choice[1], nil + } + + return "", fmt.Errorf("unexpected selection result") +}