Namespaces and TUI sorting

This commit is contained in:
Berger Eugene 2023-06-11 00:32:04 +03:00
parent ca1748240a
commit ff3a25f2dd
15 changed files with 352 additions and 122 deletions

View File

@ -23,6 +23,7 @@ Process Compose is a simple and flexible scheduler and orchestrator to manage no
- [Functions as both server and client](#-client-mode)
- Configurable shortcuts (see [Wiki](https://github.com/F1bonacc1/process-compose/wiki/Shortcuts-Configuration))
- [Merge Configuration Files](#-merge-2-or-more-configuration-files-with-override-values)
- [Namespaces](#-Namespaces)
It is heavily inspired by [docker-compose](https://github.com/docker/compose), but without the need for containers. The configuration syntax tries to follow the docker-compose specifications, with a few minor additions and lots of subtractions.
@ -340,6 +341,12 @@ processes:
Even if disabled, it is still listed in the TUI and the REST client, can be started manually when needed.
##### Processes State Columns Sorting
Sorting is performed by pressing `shift` + the letter that appears in `()` next to the column title. Pressing the same combination again will reverse the sort order.
For example: To sort by process `AGE(A)` press `shift+A`
#### ✅ <u>Logger</u>
##### ✅ Per Process Log Collection
@ -605,7 +612,17 @@ Using multiple `process-compose` files lets you to customize a `process-compose`
See the `process-compose` wiki for more information on [Multiple Compose Files](https://github.com/F1bonacc1/process-compose/wiki/Multiple-Compose-Files).
### ✅ Namespaces
Assigning namespaces to processes allows better grouping and sorting, especially in TUI:
```yaml
processes:
process1:
command: "tail -f -n100 process-compose-${USER}.log"
working_dir: "/tmp"
namespace: debug # if not defined 'default' namespaces is automatically assigned to each process
```
#### ✅ <u>Multi-platform</u>

View File

@ -69,6 +69,7 @@ processes:
process4:
command: "./test_loop.bash process4-override"
namespace: test
disable_ansi_colors: true
# availability:
# restart: on_failure

View File

@ -127,10 +127,12 @@ processes:
depends_on:
process0:
condition: process_completed
namespace: debug
__pc_log_client:
command: "tail -f -n100 process-compose-${USER}-client.log"
working_dir: "/tmp"
namespace: debug
bat_config:

View File

@ -1,7 +1,6 @@
package api
import (
"github.com/f1bonacc1/process-compose/src/types"
"net/http"
"strconv"
@ -65,23 +64,13 @@ func (api *PcApi) GetProcessInfo(c *gin.Context) {
// @Success 200 {object} object "Processes Status"
// @Router /processes [get]
func (api *PcApi) GetProcesses(c *gin.Context) {
procs, err := api.project.GetLexicographicProcessNames()
states, err := api.project.GetProcessesState()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
states := []*types.ProcessState{}
for _, name := range procs {
state, err := api.project.GetProcessState(name)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
states = append(states, state)
}
c.JSON(http.StatusOK, gin.H{"data": states})
c.JSON(http.StatusOK, states)
}
// @Schemes

View File

@ -309,6 +309,7 @@ func (p *Process) updateProcState() {
if p.isRunning() {
dur := time.Since(p.startTime)
p.procState.SystemTime = durationToString(dur)
p.procState.Age = dur
p.procState.IsRunning = true
}
}

View File

@ -20,6 +20,7 @@ type IProject interface {
GetLexicographicProcessNames() ([]string, error)
GetProcessInfo(name string) (*types.ProcessConfig, error)
GetProcessState(name string) (*types.ProcessState, error)
GetProcessesState() (*types.ProcessesState, error)
StopProcess(name string) error
StartProcess(name string) error
RestartProcess(name string) error

View File

@ -131,6 +131,7 @@ func (p *ProjectRunner) initProcessStates() {
for key, proc := range p.project.Processes {
p.processStates[key] = &types.ProcessState{
Name: key,
Namespace: proc.Namespace,
Status: types.ProcessStatePending,
SystemTime: "",
Health: types.ProcessHealthUnknown,
@ -159,6 +160,7 @@ func (p *ProjectRunner) GetProcessState(name string) (*types.ProcessState, error
} else {
procState.Pid = 0
procState.SystemTime = ""
procState.Age = time.Duration(0)
procState.Health = types.ProcessHealthUnknown
procState.IsRunning = false
}
@ -169,6 +171,20 @@ func (p *ProjectRunner) GetProcessState(name string) (*types.ProcessState, error
return nil, fmt.Errorf("no such process: %s", name)
}
func (p *ProjectRunner) GetProcessesState() (*types.ProcessesState, error) {
states := &types.ProcessesState{
States: make([]types.ProcessState, 0),
}
for name, _ := range p.processStates {
state, err := p.GetProcessState(name)
if err != nil {
continue
}
states.States = append(states.States, *state)
}
return states, nil
}
func (p *ProjectRunner) addRunningProcess(process *Process) {
p.mapMutex.Lock()
p.runningProcesses[process.getName()] = process

View File

@ -81,6 +81,10 @@ func (p *PcClient) GetProcessState(name string) (*types.ProcessState, error) {
return state, err
}
func (p *PcClient) GetProcessesState() (*types.ProcessesState, error) {
return GetProcessesState(p.address, p.port)
}
func (p *PcClient) StopProcess(name string) error {
return StopProcesses(p.address, p.port, name)
}

View File

@ -6,44 +6,38 @@ import (
"github.com/f1bonacc1/process-compose/src/types"
"github.com/rs/zerolog/log"
"net/http"
"sort"
)
func GetProcessesName(address string, port int) ([]string, error) {
url := fmt.Sprintf("http://%s:%d/processes", address, port)
resp, err := http.Get(url)
states, err := GetProcessesState(address, port)
if err != nil {
return []string{}, err
return nil, err
}
defer resp.Body.Close()
//Create a variable of the same type as our model
var sResp types.ProcessStates
//Decode the data
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
return []string{}, err
}
procs := make([]string, len(sResp.States))
for i, proc := range sResp.States {
procs := make([]string, len(states.States))
for i, proc := range states.States {
procs[i] = proc.Name
}
sort.Strings(procs)
return procs, nil
}
func GetProcessesState(address string, port int) ([]types.ProcessState, error) {
func GetProcessesState(address string, port int) (*types.ProcessesState, error) {
url := fmt.Sprintf("http://%s:%d/processes", address, port)
resp, err := http.Get(url)
if err != nil {
return []types.ProcessState{}, err
return nil, err
}
defer resp.Body.Close()
//Create a variable of the same type as our model
var sResp types.ProcessStates
var sResp types.ProcessesState
//Decode the data
if err := json.NewDecoder(resp.Body).Decode(&sResp); err != nil {
return []types.ProcessState{}, err
log.Err(err).Msgf("failed to decode process states")
return nil, err
}
return sResp.States, nil
return &sResp, nil
}
func GetProcessState(address string, port int, name string) (*types.ProcessState, error) {

View File

@ -4,6 +4,7 @@ import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/rs/zerolog/log"
"strconv"
"time"
)
@ -13,19 +14,26 @@ func (pv *pcView) fillTableData() {
return
}
runningProcCount := 0
for r, name := range pv.procNames {
state, err := pv.project.GetProcessState(name)
if err != nil || state == nil {
return
}
states, err := pv.project.GetProcessesState()
if err != nil {
log.Err(err).Msg("failed to get processes state")
return
}
sorter := pv.getTableSorter()
err = sortProcessesState(sorter.sortByColumn, sorter.isAsc, states)
if err != nil {
log.Err(err).Msg("failed to sort states")
return
}
for r, state := range states.States {
pv.procTable.SetCell(r+1, 0, tview.NewTableCell(strconv.Itoa(state.Pid)).SetAlign(tview.AlignRight).SetExpansion(0).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 1, tview.NewTableCell(state.Name).SetAlign(tview.AlignLeft).SetExpansion(1).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 2, tview.NewTableCell(state.Status).SetAlign(tview.AlignLeft).SetExpansion(1).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 3, tview.NewTableCell(state.SystemTime).SetAlign(tview.AlignLeft).SetExpansion(1).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 4, tview.NewTableCell(state.Health).SetAlign(tview.AlignLeft).SetExpansion(1).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 5, tview.NewTableCell(strconv.Itoa(state.Restarts)).SetAlign(tview.AlignRight).SetExpansion(0).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 6, tview.NewTableCell(strconv.Itoa(state.ExitCode)).SetAlign(tview.AlignRight).SetExpansion(0).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 2, tview.NewTableCell(state.Namespace).SetAlign(tview.AlignLeft).SetExpansion(1).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 3, tview.NewTableCell(state.Status).SetAlign(tview.AlignLeft).SetExpansion(1).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 4, tview.NewTableCell(state.SystemTime).SetAlign(tview.AlignLeft).SetExpansion(1).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 5, tview.NewTableCell(state.Health).SetAlign(tview.AlignLeft).SetExpansion(1).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 6, tview.NewTableCell(strconv.Itoa(state.Restarts)).SetAlign(tview.AlignRight).SetExpansion(0).SetTextColor(tcell.ColorLightSkyBlue))
pv.procTable.SetCell(r+1, 7, tview.NewTableCell(strconv.Itoa(state.ExitCode)).SetAlign(tview.AlignRight).SetExpansion(0).SetTextColor(tcell.ColorLightSkyBlue))
if state.IsRunning {
runningProcCount += 1
}
@ -54,7 +62,17 @@ func (pv *pcView) onTableSelectionChange(row, column int) {
func (pv *pcView) createProcTable() *tview.Table {
table := tview.NewTable().SetBorders(false).SetSelectable(true, false)
//pv.fillTableData()
pv.procColumns = map[ColumnID]string{
ProcessStatePid: "PID(P)",
ProcessStateName: "NAME(N)",
ProcessStateNamespace: "NAMESPACE(C)",
ProcessStateStatus: "STATUS(S)",
ProcessStateAge: "AGE(A)",
ProcessStateHealth: "HEALTH(H)",
ProcessStateRestarts: "RESTARTS(R)",
ProcessStateExit: "EXIT CODE(E)",
}
table.Select(1, 1).SetFixed(1, 0).SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case pv.shortcuts.ShortCutKeys[ActionProcessStop].key:
@ -66,27 +84,42 @@ func (pv *pcView) createProcTable() *tview.Table {
case pv.shortcuts.ShortCutKeys[ActionProcessRestart].key:
name := pv.getSelectedProcName()
pv.project.RestartProcess(name)
case tcell.KeyRune:
if event.Rune() == 'S' {
pv.setTableSorter(ProcessStateStatus)
} else if event.Rune() == 'N' {
pv.setTableSorter(ProcessStateName)
} else if event.Rune() == 'C' {
pv.setTableSorter(ProcessStateNamespace)
} else if event.Rune() == 'A' {
pv.setTableSorter(ProcessStateAge)
} else if event.Rune() == 'H' {
pv.setTableSorter(ProcessStateHealth)
} else if event.Rune() == 'R' {
pv.setTableSorter(ProcessStateRestarts)
} else if event.Rune() == 'E' {
pv.setTableSorter(ProcessStateExit)
} else if event.Rune() == 'P' {
pv.setTableSorter(ProcessStatePid)
}
}
return event
})
columns := []string{
"PID", "NAME", "STATUS", "AGE", "READINESS", "RESTARTS", "EXIT CODE",
}
for i := 0; i < len(columns); i++ {
expan := 1
for i := 0; i < len(pv.procColumns); i++ {
expansion := 10
align := tview.AlignLeft
switch columns[i] {
switch ColumnID(i) {
case
"PID":
expan = 0
ProcessStatePid:
expansion = 1
case
"RESTARTS",
"EXIT CODE":
ProcessStateRestarts,
ProcessStateExit:
align = tview.AlignRight
}
table.SetCell(0, i, tview.NewTableCell(columns[i]).
SetSelectable(false).SetExpansion(expan).SetAlign(align))
table.SetCell(0, i, tview.NewTableCell(pv.procColumns[ColumnID(i)]).
SetSelectable(false).SetExpansion(expansion).SetAlign(align))
}
table.SetSelectionChangedFunc(pv.onTableSelectionChange)
return table
@ -100,3 +133,30 @@ func (pv *pcView) updateTable() {
})
}
}
func (pv *pcView) setTableSorter(sortBy ColumnID) {
pv.sortMtx.Lock()
defer pv.sortMtx.Unlock()
prevSortColumn := ProcessStateUndefined
if pv.stateSorter.sortByColumn == sortBy {
pv.stateSorter.isAsc = !pv.stateSorter.isAsc
} else {
prevSortColumn = pv.stateSorter.sortByColumn
pv.stateSorter.sortByColumn = sortBy
pv.stateSorter.isAsc = true
}
order := "[pink]↓[-:-:-]"
if !pv.stateSorter.isAsc {
order = "[pink]↑[-:-:-]"
}
pv.procTable.GetCell(0, int(sortBy)).SetText(pv.procColumns[sortBy] + order)
if prevSortColumn != ProcessStateUndefined {
pv.procTable.GetCell(0, int(prevSortColumn)).SetText(pv.procColumns[prevSortColumn])
}
}
func (pv *pcView) getTableSorter() StateSorter {
pv.sortMtx.Lock()
defer pv.sortMtx.Unlock()
return pv.stateSorter
}

114
src/tui/procstate_sorter.go Normal file
View File

@ -0,0 +1,114 @@
package tui
import (
"fmt"
"github.com/f1bonacc1/process-compose/src/types"
"sort"
)
type ColumnID int
const (
ProcessStateUndefined ColumnID = -1
ProcessStatePid ColumnID = 0
ProcessStateName ColumnID = 1
ProcessStateNamespace ColumnID = 2
ProcessStateStatus ColumnID = 3
ProcessStateAge ColumnID = 4
ProcessStateHealth ColumnID = 5
ProcessStateRestarts ColumnID = 6
ProcessStateExit ColumnID = 7
)
type StateSorter struct {
sortByColumn ColumnID
isAsc bool
}
type sortFn func(i, j int) bool
func sortProcessesState(sortBy ColumnID, asc bool, states *types.ProcessesState) error {
if states == nil {
return fmt.Errorf("empty states")
}
sorter := getSorter(sortBy, states)
if !asc {
sorter = reverse(sorter)
}
sort.Slice(states.States, sorter)
return nil
}
func getSorter(sortBy ColumnID, states *types.ProcessesState) sortFn {
switch sortBy {
case ProcessStatePid:
return func(i, j int) bool {
if states.States[i].Pid == states.States[j].Pid {
return states.States[i].Name < states.States[j].Name
} else {
return states.States[i].Pid < states.States[j].Pid
}
}
case ProcessStateNamespace:
return func(i, j int) bool {
if states.States[i].Namespace == states.States[j].Namespace {
return states.States[i].Name < states.States[j].Name
} else {
return states.States[i].Namespace < states.States[j].Namespace
}
}
case ProcessStateStatus:
return func(i, j int) bool {
if states.States[i].Status == states.States[j].Status {
return states.States[i].Name < states.States[j].Name
} else {
return states.States[i].Status < states.States[j].Status
}
}
case ProcessStateAge:
return func(i, j int) bool {
if states.States[i].Age == states.States[j].Age {
return states.States[i].Name < states.States[j].Name
} else {
return states.States[i].Age < states.States[j].Age
}
}
case ProcessStateHealth:
return func(i, j int) bool {
if states.States[i].Health == states.States[j].Health {
return states.States[i].Name < states.States[j].Name
} else {
return states.States[i].Health < states.States[j].Health
}
}
case ProcessStateRestarts:
return func(i, j int) bool {
if states.States[i].Restarts == states.States[j].Restarts {
return states.States[i].Name < states.States[j].Name
} else {
return states.States[i].Restarts < states.States[j].Restarts
}
}
case ProcessStateExit:
return func(i, j int) bool {
if states.States[i].ExitCode == states.States[j].ExitCode {
return states.States[i].Name < states.States[j].Name
} else {
return states.States[i].ExitCode < states.States[j].ExitCode
}
}
case ProcessStateName:
fallthrough
default:
return func(i, j int) bool {
return states.States[i].Name < states.States[j].Name
}
}
}
func reverse(less func(i, j int) bool) func(i, j int) bool {
return func(i, j int) bool {
return !less(i, j)
}
}

View File

@ -5,6 +5,7 @@ import (
"github.com/f1bonacc1/process-compose/src/client"
"github.com/f1bonacc1/process-compose/src/config"
"github.com/f1bonacc1/process-compose/src/updater"
"sync"
"time"
"github.com/f1bonacc1/process-compose/src/app"
@ -47,15 +48,17 @@ type pcView struct {
mainGrid *tview.Grid
logsTextArea *tview.TextArea
project app.IProject
sortMtx sync.Mutex
stateSorter StateSorter
procColumns map[ColumnID]string
}
func newPcView(project app.IProject) *pcView {
//_ = pv.shortcuts.loadFromFile("short-cuts-new.yaml")
pv := &pcView{
appView: tview.NewApplication(),
logsText: NewLogView(project.GetLogLength()),
statusText: tview.NewTextView().SetDynamicColors(true),
appView: tview.NewApplication(),
logsText: NewLogView(project.GetLogLength()),
statusText: tview.NewTextView().SetDynamicColors(true),
logFollow: true,
fullScrState: LogProcHalf,
helpText: tview.NewTextView().SetDynamicColors(true),
@ -66,6 +69,11 @@ func newPcView(project app.IProject) *pcView {
logsTextArea: tview.NewTextArea(),
logSelect: false,
project: project,
stateSorter: StateSorter{
sortByColumn: ProcessStateName,
isAsc: true,
},
procColumns: map[ColumnID]string{},
}
pv.statTable = pv.createStatTable()
go pv.loadProcNames()
@ -240,7 +248,7 @@ func (pv *pcView) getSelectedProcName() string {
}
row, _ := pv.procTable.GetSelection()
if row > 0 && row <= len(pv.procNames) {
return pv.procNames[row-1]
return pv.procTable.GetCell(row, 1).Text
}
return ""
}

View File

@ -1,6 +1,11 @@
package types
import "github.com/f1bonacc1/process-compose/src/health"
import (
"github.com/f1bonacc1/process-compose/src/health"
"time"
)
const DefaultNamespace = "default"
type Processes map[string]ProcessConfig
type Environment []string
@ -18,6 +23,7 @@ type ProcessConfig struct {
ShutDownParams ShutDownParams `yaml:"shutdown,omitempty"`
DisableAnsiColors bool `yaml:"disable_ansi_colors,omitempty"`
WorkingDir string `yaml:"working_dir"`
Namespace string `yaml:"namespace"`
Extensions map[string]interface{} `yaml:",inline"`
}
@ -33,17 +39,19 @@ func (p ProcessConfig) GetDependencies() []string {
}
type ProcessState struct {
Name string `json:"name"`
Status string `json:"status"`
SystemTime string `json:"system_time"`
Health string `json:"is_ready"`
Restarts int `json:"restarts"`
ExitCode int `json:"exit_code"`
Pid int `json:"pid"`
Name string `json:"name"`
Namespace string `json:"namespace"`
Status string `json:"status"`
SystemTime string `json:"system_time"`
Age time.Duration `json:"age"`
Health string `json:"is_ready"`
Restarts int `json:"restarts"`
ExitCode int `json:"exit_code"`
Pid int `json:"pid"`
IsRunning bool
}
type ProcessStates struct {
type ProcessesState struct {
States []ProcessState `json:"data"`
}

View File

@ -3,10 +3,7 @@ package types
import (
"fmt"
"github.com/f1bonacc1/process-compose/src/command"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"sort"
"strings"
)
type Project struct {
@ -19,56 +16,6 @@ type Project struct {
ShellConfig *command.ShellConfig `yaml:"shell,omitempty"`
}
func (p *Project) Validate() {
p.validateLogLevel()
p.setConfigDefaults()
p.deprecationCheck()
p.validateProcessConfig()
}
func (p *Project) validateLogLevel() {
if p.LogLevel != "" {
lvl, err := zerolog.ParseLevel(p.LogLevel)
if err != nil {
log.Warn().Msgf("Unknown log level %s defaulting to %s",
p.LogLevel, zerolog.GlobalLevel().String())
} else {
zerolog.SetGlobalLevel(lvl)
}
}
}
func (p *Project) setConfigDefaults() {
if p.ShellConfig == nil {
p.ShellConfig = command.DefaultShellConfig()
}
log.Info().Msgf("Global shell command: %s %s", p.ShellConfig.ShellCommand, p.ShellConfig.ShellArgument)
command.ValidateShellConfig(*p.ShellConfig)
}
func (p *Project) deprecationCheck() {
for key, proc := range p.Processes {
if proc.RestartPolicy.Restart == RestartPolicyOnFailureDeprecated {
deprecationHandler("2022-10-30", key, RestartPolicyOnFailureDeprecated, RestartPolicyOnFailure, "restart policy")
}
}
}
func (p *Project) validateProcessConfig() {
for key, proc := range p.Processes {
if len(proc.Extensions) == 0 {
continue
}
for extKey := range proc.Extensions {
if strings.HasPrefix(extKey, "x-") {
continue
}
log.Error().Msgf("Unknown key %s found in process %s", extKey, key)
}
}
}
type ProcessFunc func(process ProcessConfig) error
// WithProcesses run ProcesseFunc on each Process and dependencies in dependency order

68
src/types/validators.go Normal file
View File

@ -0,0 +1,68 @@
package types
import (
"github.com/f1bonacc1/process-compose/src/command"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"strings"
)
func (p *Project) Validate() {
p.validateLogLevel()
p.setConfigDefaults()
p.deprecationCheck()
p.validateProcessConfig()
p.assignDefaultNamespace()
}
func (p *Project) validateLogLevel() {
if p.LogLevel != "" {
lvl, err := zerolog.ParseLevel(p.LogLevel)
if err != nil {
log.Warn().Msgf("Unknown log level %s defaulting to %s",
p.LogLevel, zerolog.GlobalLevel().String())
} else {
zerolog.SetGlobalLevel(lvl)
}
}
}
func (p *Project) setConfigDefaults() {
if p.ShellConfig == nil {
p.ShellConfig = command.DefaultShellConfig()
}
log.Info().Msgf("Global shell command: %s %s", p.ShellConfig.ShellCommand, p.ShellConfig.ShellArgument)
command.ValidateShellConfig(*p.ShellConfig)
}
func (p *Project) deprecationCheck() {
for key, proc := range p.Processes {
if proc.RestartPolicy.Restart == RestartPolicyOnFailureDeprecated {
deprecationHandler("2022-10-30", key, RestartPolicyOnFailureDeprecated, RestartPolicyOnFailure, "restart policy")
}
}
}
func (p *Project) validateProcessConfig() {
for key, proc := range p.Processes {
if len(proc.Extensions) == 0 {
continue
}
for extKey := range proc.Extensions {
if strings.HasPrefix(extKey, "x-") {
continue
}
log.Error().Msgf("Unknown key %s found in process %s", extKey, key)
}
}
}
func (p *Project) assignDefaultNamespace() {
for name, proc := range p.Processes {
if proc.Namespace == "" {
proc.Namespace = DefaultNamespace
p.Processes[name] = proc
}
}
}