diff --git a/client/watch.go b/client/watch.go index 23f8d12..835c5ac 100644 --- a/client/watch.go +++ b/client/watch.go @@ -6,9 +6,11 @@ import ( "math" "strings" "time" + "unicode/utf8" "github.com/gammadia/alfred/client/ui" "github.com/gammadia/alfred/proto" + "github.com/rivo/uniseg" "github.com/samber/lo" "github.com/spf13/cobra" ) @@ -26,6 +28,9 @@ var watchCmd = &cobra.Command{ spinner := ui.NewSpinner("Waiting for job data") + emoji := func(emoji string) string { + return emoji + strings.Repeat(" ", utf8.RuneCountInString(emoji)) + } itemsPrinter := func(items []string, last bool) string { nbItems := len(items) if nbItems < 1 { @@ -33,11 +38,12 @@ var watchCmd = &cobra.Command{ } // Try to display the first or last 20 items, as long as displaying them doesn't exceed 180 characters... - // ... except in verbose mode, where we go a bit crazier. + // ... except in verbose mode, where we display everything (it might be ugly while it's running, but it's + // very useful once it's finished). displayItems := 20 lineLength := 180 if verbose { - displayItems = 250 + displayItems = math.MaxInt32 lineLength = math.MaxInt32 } partial := nbItems > displayItems @@ -48,7 +54,7 @@ var watchCmd = &cobra.Command{ } else { nItems = items[:min(nbItems, displayItems)] } - if len(strings.Join(nItems, " ")) <= lineLength { + if uniseg.GraphemeClusterCount(strings.Join(nItems, " ")) <= lineLength { break } displayItems -= 1 @@ -56,19 +62,23 @@ var watchCmd = &cobra.Command{ } if last { - return fmt.Sprintf("%s%s (📝 %d)", lo.Ternary(partial, "… ", ""), strings.Join(nItems, " "), nbItems) + return fmt.Sprintf("%s%s (%s%d)", lo.Ternary(partial, "… ", ""), strings.Join(nItems, " "), emoji("📝"), nbItems) } - return fmt.Sprintf("%s%s (📝 %d)", strings.Join(nItems, " "), lo.Ternary(partial, " …", ""), nbItems) + return fmt.Sprintf("%s%s (%s%d)", strings.Join(nItems, " "), lo.Ternary(partial, " …", ""), emoji("📝"), nbItems) } var stats, timestamp string var tasks []string + someTime := lo.Must(time.ParseDuration("30m")) + aLongTime := lo.Must(time.ParseDuration("2h")) + aVeryLongTime := lo.Must(time.ParseDuration("4h")) + for { msg, err := c.Recv() if err != nil { if err == io.EOF { - spinner.Success(fmt.Sprintf("Job '%s' completed (📝 %d, %s)\n%s", args[0], len(tasks), timestamp, stats)) + spinner.Success(fmt.Sprintf("Job '%s' completed (%s%d, %s)\n%s", args[0], emoji("📝"), len(tasks), timestamp, stats)) return nil } spinner.Fail() @@ -84,53 +94,74 @@ var watchCmd = &cobra.Command{ tasks = lo.Map(msg.Tasks, func(t *proto.TaskStatus, _ int) string { return t.Name }) for _, t := range msg.Tasks { + label := t.Name + + if t.StartedAt != nil { + var taskRunningFor time.Duration + if t.CompletedAt != nil { + taskRunningFor = t.CompletedAt.AsTime().Sub(t.StartedAt.AsTime()).Truncate(time.Second) + } else { + taskRunningFor = time.Since(t.StartedAt.AsTime()).Truncate(time.Second) + } + if taskRunningFor >= someTime { + label += fmt.Sprintf(" (%s%s)", emoji(lo.Ternary(taskRunningFor >= aLongTime, "🧟", "🐢")), taskRunningFor) + } + } else { + taskQueuedFor := time.Since(msg.ScheduledAt.AsTime()).Truncate(time.Second) + if taskQueuedFor >= aVeryLongTime { + label += fmt.Sprintf(" (%s%s)", emoji("😴"), taskQueuedFor) + } + } + switch t.Status { case proto.TaskStatus_QUEUED: - queued = append(queued, t.Name) + queued = append(queued, label) case proto.TaskStatus_RUNNING: - running = append(running, t.Name) + running = append(running, label) case proto.TaskStatus_ABORTED: - aborted = append(aborted, t.Name) + aborted = append(aborted, label) case proto.TaskStatus_FAILED: if *t.ExitCode == 42 { - failures = append(failures, t.Name) + failures = append(failures, label) } else { - crashed = append(crashed, t.Name) + crashed = append(crashed, label) } case proto.TaskStatus_COMPLETED: - completed = append(completed, t.Name) + completed = append(completed, label) } } statItems := []string{} if len(queued) > 0 { - statItems = append(statItems, fmt.Sprintf("⏳ %s", itemsPrinter(queued, false))) + statItems = append(statItems, emoji("⏳")+itemsPrinter(queued, false)) } if len(running) > 0 { - statItems = append(statItems, fmt.Sprintf("⚙️ %s", itemsPrinter(running, false))) + statItems = append(statItems, emoji("⚙️")+itemsPrinter(running, false)) } if len(aborted) > 0 { - statItems = append(statItems, fmt.Sprintf("🛑 %s", itemsPrinter(aborted, true))) + statItems = append(statItems, emoji("🛑")+itemsPrinter(aborted, true)) } if len(crashed) > 0 { - statItems = append(statItems, fmt.Sprintf("💥 %s", itemsPrinter(crashed, true))) + statItems = append(statItems, emoji("💥")+itemsPrinter(crashed, true)) } if len(failures) > 0 { - statItems = append(statItems, fmt.Sprintf("⚠️ %s", itemsPrinter(failures, true))) + statItems = append(statItems, emoji("⚠️")+itemsPrinter(failures, true)) } if len(completed) > 0 { - statItems = append(statItems, fmt.Sprintf("✅ %s", itemsPrinter(completed, true))) + statItems = append(statItems, emoji("✅")+itemsPrinter(completed, true)) } stats = strings.Join(statItems, "\n") if msg.CompletedAt != nil { - timestamp = fmt.Sprintf("🏁 %s", msg.CompletedAt.AsTime().Sub(msg.ScheduledAt.AsTime()).Truncate(time.Second)) + timestamp = emoji("🏁") + fmt.Sprintf("%s", msg.CompletedAt.AsTime().Sub(msg.ScheduledAt.AsTime()).Truncate(time.Second)) } else { - timestamp = fmt.Sprintf("⏱️ %s", time.Since(msg.ScheduledAt.AsTime()).Truncate(time.Second)) + jobRunningFor := time.Since(msg.ScheduledAt.AsTime()).Truncate(time.Second) + jobRunningForEmoji := lo.Ternary(jobRunningFor >= aLongTime, lo.Ternary(jobRunningFor >= aVeryLongTime, "🧟", "🐢"), "⏱️") + timestamp = emoji(jobRunningForEmoji) + fmt.Sprintf("%s", jobRunningFor) } - spinner.UpdateMessage(fmt.Sprintf("Job '%s' running (📝 %d, %s)\n%s", args[0], len(tasks), timestamp, stats)) + spinner.UpdateMessage(fmt.Sprintf("Job '%s' running (%s%d, %s)\n%s", args[0], emoji("📝"), len(tasks), timestamp, stats)) } }, } diff --git a/go.mod b/go.mod index d4daa46..4527f82 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/fatih/color v1.15.0 github.com/gophercloud/gophercloud v1.7.0 github.com/rivo/tview v0.0.0-20231102183219-1b91b8131c43 + github.com/rivo/uniseg v0.4.4 github.com/samber/lo v1.38.1 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 @@ -48,7 +49,6 @@ require ( github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rivo/uniseg v0.4.4 // indirect github.com/sagikazarmark/locafero v0.3.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/proto/alfred.pb.go b/proto/alfred.pb.go index 762745c..3aebf19 100644 --- a/proto/alfred.pb.go +++ b/proto/alfred.pb.go @@ -1025,9 +1025,11 @@ type TaskStatus struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Status TaskStatus_Status `protobuf:"varint,2,opt,name=status,proto3,enum=proto.TaskStatus_Status" json:"status,omitempty"` - ExitCode *int32 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3,oneof" json:"exit_code,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Status TaskStatus_Status `protobuf:"varint,2,opt,name=status,proto3,enum=proto.TaskStatus_Status" json:"status,omitempty"` + ExitCode *int32 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3,oneof" json:"exit_code,omitempty"` + StartedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + CompletedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=completed_at,json=completedAt,proto3,oneof" json:"completed_at,omitempty"` } func (x *TaskStatus) Reset() { @@ -1083,6 +1085,20 @@ func (x *TaskStatus) GetExitCode() int32 { return 0 } +func (x *TaskStatus) GetStartedAt() *timestamppb.Timestamp { + if x != nil { + return x.StartedAt + } + return nil +} + +func (x *TaskStatus) GetCompletedAt() *timestamppb.Timestamp { + if x != nil { + return x.CompletedAt + } + return nil +} + type NodeStatus struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -1854,7 +1870,7 @@ var file_proto_alfred_proto_rawDesc = []byte{ 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, 0x00, 0x52, 0x0b, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x88, 0x01, 0x01, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x63, - 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x22, 0xda, 0x01, 0x0a, 0x0a, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x22, 0xea, 0x02, 0x0a, 0x0a, 0x54, 0x61, 0x73, 0x6b, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x30, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, @@ -1862,13 +1878,22 @@ var file_proto_alfred_proto_rawDesc = []byte{ 0x73, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x20, 0x0a, 0x09, 0x65, 0x78, 0x69, 0x74, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, 0x65, 0x78, 0x69, 0x74, 0x43, 0x6f, 0x64, 0x65, 0x88, - 0x01, 0x01, 0x22, 0x56, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, - 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x51, 0x55, 0x45, - 0x55, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, - 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x41, 0x42, 0x4f, 0x52, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, - 0x0a, 0x0a, 0x06, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x0d, 0x0a, 0x09, 0x43, - 0x4f, 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x05, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x65, - 0x78, 0x69, 0x74, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x22, 0xa9, 0x03, 0x0a, 0x0a, 0x4e, 0x6f, 0x64, + 0x01, 0x01, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x42, 0x0a, + 0x0c, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x48, + 0x01, 0x52, 0x0b, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x41, 0x74, 0x88, 0x01, + 0x01, 0x22, 0x56, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x55, + 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x51, 0x55, 0x45, 0x55, + 0x45, 0x44, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x52, 0x55, 0x4e, 0x4e, 0x49, 0x4e, 0x47, 0x10, + 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x41, 0x42, 0x4f, 0x52, 0x54, 0x45, 0x44, 0x10, 0x03, 0x12, 0x0a, + 0x0a, 0x06, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x4f, + 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x05, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x65, 0x78, + 0x69, 0x74, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x63, 0x6f, 0x6d, 0x70, + 0x6c, 0x65, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x22, 0xa9, 0x03, 0x0a, 0x0a, 0x4e, 0x6f, 0x64, 0x65, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x30, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x18, 0x2e, 0x70, 0x72, @@ -1990,31 +2015,33 @@ var file_proto_alfred_proto_depIdxs = []int32{ 29, // 14: proto.JobStatus.scheduled_at:type_name -> google.protobuf.Timestamp 29, // 15: proto.JobStatus.completed_at:type_name -> google.protobuf.Timestamp 1, // 16: proto.TaskStatus.status:type_name -> proto.TaskStatus.Status - 2, // 17: proto.NodeStatus.status:type_name -> proto.NodeStatus.Status - 27, // 18: proto.NodeStatus.slots:type_name -> proto.NodeStatus.Slot - 20, // 19: proto.Job.Service.env:type_name -> proto.Job.Env - 21, // 20: proto.Job.Service.health:type_name -> proto.Job.Service.Health - 30, // 21: proto.Job.Service.Health.timeout:type_name -> google.protobuf.Duration - 30, // 22: proto.Job.Service.Health.interval:type_name -> google.protobuf.Duration - 29, // 23: proto.Status.Server.started_at:type_name -> google.protobuf.Timestamp - 28, // 24: proto.NodeStatus.Slot.task:type_name -> proto.NodeStatus.Slot.Task - 4, // 25: proto.Alfred.DownloadArtifact:input_type -> proto.DownloadArtifactRequest - 6, // 26: proto.Alfred.LoadImage:input_type -> proto.LoadImageMessage - 8, // 27: proto.Alfred.ScheduleJob:input_type -> proto.ScheduleJobRequest - 10, // 28: proto.Alfred.Ping:input_type -> proto.PingRequest - 12, // 29: proto.Alfred.WatchJob:input_type -> proto.WatchJobRequest - 13, // 30: proto.Alfred.WatchJobs:input_type -> proto.WatchJobsRequest - 5, // 31: proto.Alfred.DownloadArtifact:output_type -> proto.DownloadArtifactChunk - 7, // 32: proto.Alfred.LoadImage:output_type -> proto.LoadImageResponse - 9, // 33: proto.Alfred.ScheduleJob:output_type -> proto.ScheduleJobResponse - 11, // 34: proto.Alfred.Ping:output_type -> proto.PingResponse - 16, // 35: proto.Alfred.WatchJob:output_type -> proto.JobStatus - 14, // 36: proto.Alfred.WatchJobs:output_type -> proto.JobsList - 31, // [31:37] is the sub-list for method output_type - 25, // [25:31] is the sub-list for method input_type - 25, // [25:25] is the sub-list for extension type_name - 25, // [25:25] is the sub-list for extension extendee - 0, // [0:25] is the sub-list for field type_name + 29, // 17: proto.TaskStatus.started_at:type_name -> google.protobuf.Timestamp + 29, // 18: proto.TaskStatus.completed_at:type_name -> google.protobuf.Timestamp + 2, // 19: proto.NodeStatus.status:type_name -> proto.NodeStatus.Status + 27, // 20: proto.NodeStatus.slots:type_name -> proto.NodeStatus.Slot + 20, // 21: proto.Job.Service.env:type_name -> proto.Job.Env + 21, // 22: proto.Job.Service.health:type_name -> proto.Job.Service.Health + 30, // 23: proto.Job.Service.Health.timeout:type_name -> google.protobuf.Duration + 30, // 24: proto.Job.Service.Health.interval:type_name -> google.protobuf.Duration + 29, // 25: proto.Status.Server.started_at:type_name -> google.protobuf.Timestamp + 28, // 26: proto.NodeStatus.Slot.task:type_name -> proto.NodeStatus.Slot.Task + 4, // 27: proto.Alfred.DownloadArtifact:input_type -> proto.DownloadArtifactRequest + 6, // 28: proto.Alfred.LoadImage:input_type -> proto.LoadImageMessage + 8, // 29: proto.Alfred.ScheduleJob:input_type -> proto.ScheduleJobRequest + 10, // 30: proto.Alfred.Ping:input_type -> proto.PingRequest + 12, // 31: proto.Alfred.WatchJob:input_type -> proto.WatchJobRequest + 13, // 32: proto.Alfred.WatchJobs:input_type -> proto.WatchJobsRequest + 5, // 33: proto.Alfred.DownloadArtifact:output_type -> proto.DownloadArtifactChunk + 7, // 34: proto.Alfred.LoadImage:output_type -> proto.LoadImageResponse + 9, // 35: proto.Alfred.ScheduleJob:output_type -> proto.ScheduleJobResponse + 11, // 36: proto.Alfred.Ping:output_type -> proto.PingResponse + 16, // 37: proto.Alfred.WatchJob:output_type -> proto.JobStatus + 14, // 38: proto.Alfred.WatchJobs:output_type -> proto.JobsList + 33, // [33:39] is the sub-list for method output_type + 27, // [27:33] is the sub-list for method input_type + 27, // [27:27] is the sub-list for extension type_name + 27, // [27:27] is the sub-list for extension extendee + 0, // [0:27] is the sub-list for field type_name } func init() { file_proto_alfred_proto_init() } diff --git a/proto/alfred.proto b/proto/alfred.proto index 21b4f62..05ac13a 100644 --- a/proto/alfred.proto +++ b/proto/alfred.proto @@ -141,6 +141,8 @@ message TaskStatus { string name = 1; Status status = 2; optional int32 exit_code = 3; + google.protobuf.Timestamp started_at = 4; + optional google.protobuf.Timestamp completed_at = 5; enum Status { UNKNOWN = 0; diff --git a/server/status.go b/server/status.go index 85cf7e2..c6f9a30 100644 --- a/server/status.go +++ b/server/status.go @@ -102,6 +102,7 @@ func listenEvents(c <-chan schedulerpkg.Event) { for _, task := range job.Tasks { if task.Name == event.Task { task.Status = proto.TaskStatus_RUNNING + task.StartedAt = timestamppb.Now() break } } @@ -127,6 +128,9 @@ func listenEvents(c <-chan schedulerpkg.Event) { if task.Name == event.Task { task.Status = proto.TaskStatus_FAILED task.ExitCode = lo.ToPtr(int32(event.ExitCode)) + if event.ExitCode == 42 { + task.CompletedAt = timestamppb.Now() + } break } } @@ -140,6 +144,7 @@ func listenEvents(c <-chan schedulerpkg.Event) { if task.Name == event.Task { task.Status = proto.TaskStatus_COMPLETED task.ExitCode = lo.ToPtr(int32(0)) + task.CompletedAt = timestamppb.Now() break } }