diff --git a/.gitignore b/.gitignore index 37d5196..80686e6 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ tools/npm/node_modules *.DS_Store # ignore built binary in root dir /zcli + +# claude code local settings +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..72d773a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,443 @@ +# CLAUDE.md - zcli Developer Guide + +## Project Overview + +**zcli** is the official Command Line Interface for [Zerops](https://zerops.io/), a cloud platform for deploying and managing applications. Written in Go 1.24, it provides comprehensive functionality for project management, service deployment, VPN connectivity, and log streaming. + +**Repository**: `github.com/zeropsio/zcli` + +### Supported Platforms +- Linux (amd64, i386) +- macOS (amd64, arm64) +- Windows (amd64) +- NixOS + +--- + +## Quick Reference + +### Essential Commands +```bash +# Build +go build -o zcli ./cmd/zcli/main.go + +# Test +go test -v ./cmd/... ./src/... + +# Lint (multi-platform) +make lint + +# Build all platforms +make all + +# Run UI showcase +make showcase +``` + +### Project Structure +``` +zcli/ +├── cmd/zcli/main.go # Entry point +├── src/ +│ ├── cmd/ # CLI commands (login, project, service, vpn, etc.) +│ ├── cmdBuilder/ # Command construction framework (wraps Cobra) +│ ├── entity/ # Domain models (Project, Service, Org, etc.) +│ ├── uxBlock/ # Terminal UI components (Bubble Tea-based) +│ ├── zeropsRestApiClient/ # API client wrapper +│ ├── cliStorage/ # Local credential/config storage +│ ├── i18n/ # Internationalization (English translations) +│ └── ... # Supporting packages +├── tools/ # Build scripts and npm package +└── .github/workflows/ # CI/CD pipelines +``` + +--- + +## Architecture Deep Dive + +### Command Framework (`src/cmdBuilder/`) + +The CLI uses a custom wrapper around [Cobra](https://github.com/spf13/cobra) with a fluent builder pattern: + +```go +func myCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("mycommand"). + Short(i18n.T(i18n.CmdDescMyCommand)). + ScopeLevel(cmdBuilder.ScopeProject()). // Requires project context + Arg("argName", cmdBuilder.OptionalArg()). + StringFlag("flag-name", "default", i18n.T(i18n.FlagDesc)). + BoolFlag("verbose", false, i18n.T(i18n.VerboseFlag)). + HelpFlag(i18n.T(i18n.CmdHelpMyCommand)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + // Implementation for authenticated users + return nil + }). + GuestRunFunc(func(ctx context.Context, cmdData *cmdBuilder.GuestCmdData) error { + // Implementation for unauthenticated users + return nil + }) +} +``` + +**Key Types**: +- `LoggedUserCmdData`: Contains `RestApiClient`, `CliStorage`, `UxBlocks`, `Project`, `Service`, `Params`, `Args` +- `GuestCmdData`: Limited context for unauthenticated commands +- `ScopeLevel`: Interface for project/service scope resolution + +### Scope System + +Commands can require different scope levels: +- `cmdBuilder.ScopeProject()` - Requires project selection +- `cmdBuilder.ScopeService()` - Requires service selection within a project +- Options: `WithCreateNewProject()`, `WithCreateNewService()`, `WithSkipSelectProject()` + +The scope system handles: +1. Persisted scope from `zcli scope project` +2. CLI flags (`--project-id`, `--service-id`) +3. Positional arguments +4. Interactive selection (when in terminal mode) + +### Entity Models (`src/entity/`) + +Core domain objects: +```go +type Project struct { + Id uuid.ProjectId + Name types.String + Mode enum.ProjectModeEnum + OrgId uuid.ClientId + OrgName types.String + Description types.Text + Status enum.ProjectStatusEnum +} + +type Service struct { + Id uuid.ServiceStackId + ProjectId uuid.ProjectId + Name types.String + Status enum.ServiceStackStatusEnum + ServiceTypeId stringId.ServiceStackTypeId + ServiceTypeCategory enum.ServiceStackTypeCategoryEnum +} +``` + +### UX Components (`src/uxBlock/`) + +Built on [Bubble Tea](https://github.com/charmbracelet/bubbletea) and [Lipgloss](https://github.com/charmbracelet/lipgloss): + +- **Spinners**: Async operation progress (`uxBlock.Spinner`) +- **Selectors**: Interactive list selection (`models/selector/`) +- **Prompts**: Yes/No confirmations (`models/prompt/`) +- **Inputs**: Text input with validation (`models/input/`) +- **Tables**: Data display (`models/table/`) +- **Log Views**: Streaming log display (`models/logView/`) + +**Usage Pattern**: +```go +err := uxHelpers.ProcessCheckWithSpinner(ctx, cmdData.UxBlocks, []uxHelpers.Process{{ + F: myAsyncFunc, + RunningMessage: "Processing...", + SuccessMessage: "Done!", + ErrorMessageMessage: "Failed!", +}}) +``` + +### Storage (`src/cliStorage/`) + +Local JSON file storage for credentials and state: +```go +type Data struct { + Token string + RegionData region.Item + ScopeProjectId uuid.ProjectIdNull + ProjectVpnKeyRegistry map[uuid.ProjectId]entity.VpnKey +} +``` + +Storage paths vary by OS (see `src/constants/`): +- macOS: `~/Library/Application Support/zerops/` or `~/.zerops/` +- Linux: `~/.config/zerops/` or `~/.zerops/` +- Windows: `%APPDATA%\Zerops\` + +### API Client (`src/zeropsRestApiClient/`) + +Wraps the `github.com/zeropsio/zerops-go` SDK: +```go +client := zeropsRestApiClient.NewAuthorizedClient(token, regionUrl) +response, err := client.GetUserInfo(ctx) +``` + +### Internationalization (`src/i18n/`) + +All user-facing strings use the translation system: +```go +i18n.T(i18n.LoginSuccess, fullName, email) // "You are logged as %s <%s>" +``` + +Constants are defined in `i18n.go`, translations in `en.go`. + +--- + +## Key Workflows + +### Push/Deploy Flow + +1. Read `zerops.yml` from working directory +2. Validate YAML against service configuration +3. Create app version via API +4. Archive files (respecting `.gitignore` and `.deployignore`) +5. Stream archive to Zerops +6. Trigger build/deploy pipeline +7. Poll process status until completion + +### VPN Connection (`vpn up`) + +1. Check for existing WireGuard interface +2. Get or create private key (stored per-project) +3. Exchange public key with Zerops API +4. Generate WireGuard config file +5. Execute `wg-quick up` +6. Verify connectivity via ping + +--- + +## Testing + +### Running Tests +```bash +go test -v ./cmd/... ./src/... +``` + +### Test Patterns +- Table-driven tests with `testify/require` +- Mock generation via `github.com/golang/mock/mockgen` +- See `src/uxBlock/mocks/` for mock examples + +### Example Test Structure +```go +func TestConvertArgs(t *testing.T) { + tests := []struct { + name string + args args + want map[string][]string + wantErr string + }{ + // test cases... + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // assertions + }) + } +} +``` + +--- + +## Build & Release + +### Local Development Build +```bash +./tools/build.sh zcli # Uses git branch/tag for version +GOOS=darwin GOARCH=arm64 ./tools/build.sh zcli.darwin +``` + +### Production Build (via CI) +```bash +go build \ + -o zcli \ + -ldflags "-s -w -X github.com/zeropsio/zcli/src/version.version=v1.0.0" \ + ./cmd/zcli/main.go +``` + +### CI/CD Workflows (`.github/workflows/`) + +| Workflow | Trigger | Purpose | +|----------|---------|---------| +| `main.yml` | Push/PR to main | Build, test, lint (multi-platform) | +| `release.yml` | GitHub release | Build binaries, publish to NPM, notify Discord | +| `pre-release.yml` | Pre-release | Preview builds | + +### Release Artifacts +- `zcli-linux-amd64`, `zcli-linux-i386` +- `zcli-darwin-amd64`, `zcli-darwin-arm64` +- `zcli-win-x64.exe` +- NPM package: `@zerops/zcli` + +--- + +## Linting + +Uses `golangci-lint` v1.64.7 with extensive ruleset (see `.golangci.yaml`): + +```bash +# Install +./tools/install.sh + +# Run +gomodrun golangci-lint run ./cmd/... ./src/... --verbose +``` + +Key enabled linters: `gosec`, `govet`, `errcheck`, `staticcheck`, `gocritic`, `gosimple`, `ineffassign`, `unused`, and 60+ more. + +--- + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `ZEROPS_TOKEN` | Authentication token (takes precedence over stored login) | +| `ZEROPS_TERMINAL_MODE` | Terminal mode: `auto`, `enabled`, `disabled` | +| `ZEROPS_LOG_FILE_PATH` | Custom log file path | +| `ZEROPS_DATA_FILE_PATH` | Custom data file path | +| `ZEROPS_WG_CONFIG_PATH` | Custom WireGuard config path | +| `ZEROPS_VERSIONNAME` | Custom version name for deployments | + +--- + +## Generic Utilities (`src/gn/`) + +Reusable generic functions used throughout: + +```go +gn.Must(value, err) // Panic on error +gn.Ptr(value) // Get pointer to value +gn.FilterSlice(slice, predicate) // Filter slice +gn.TransformSlice(slice, transform) // Map slice +gn.FindFirst(slice, predicate) // Find first match +gn.ApplyOptions(options...) // Functional options pattern +gn.MergeMaps(maps...) // Merge multiple maps +gn.IsOneOf(val, values...) // Check membership +``` + +--- + +## Adding New Commands + +1. Create `src/cmd/myCommand.go`: +```go +package cmd + +func myCommandCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("my-command"). + Short(i18n.T(i18n.CmdDescMyCommand)). + // ... configure flags, args, scope + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + // Implementation + return nil + }) +} +``` + +2. Register in parent command (e.g., `src/cmd/root.go`): +```go +AddChildrenCmd(myCommandCmd()) +``` + +3. Add i18n strings to `src/i18n/i18n.go` and `src/i18n/en.go` + +4. Write tests in `src/cmd/myCommand_test.go` + +--- + +## Error Handling + +### User Errors +```go +return errorsx.NewUserError("message", originalErr) +``` + +### API Error Conversion +```go +return errorsx.Convert(err, + errorsx.ErrorCode(errorCode.ProjectNotFound), + errorsx.InvalidUserInput("fieldName"), +) +``` + +### Error Display +Errors are automatically formatted via `cmdBuilder.printError()`: +- User errors: Display message only +- API errors: Display message + meta in YAML +- Ctrl+C: Display "canceled" info + +--- + +## Dependencies + +### Core +- `github.com/spf13/cobra` - CLI framework +- `github.com/spf13/viper` - Configuration +- `github.com/zeropsio/zerops-go` - Zerops API SDK + +### UI +- `github.com/charmbracelet/bubbletea` - TUI framework +- `github.com/charmbracelet/lipgloss` - Styling +- `github.com/charmbracelet/bubbles` - UI components + +### VPN +- `golang.zx2c4.com/wireguard/wgctrl` - WireGuard control + +### Utilities +- `github.com/pkg/errors` - Error wrapping +- `github.com/google/uuid` - UUIDs +- `github.com/gorilla/websocket` - WebSocket (logs) +- `gopkg.in/yaml.v3` - YAML parsing + +--- + +## Common Patterns + +### Checking Terminal Mode +```go +if !terminal.IsTerminal() { + return errors.New("Interactive selection requires terminal") +} +``` + +### Process Monitoring +```go +uxHelpers.CheckZeropsProcess(processId, cmdData.RestApiClient) +``` + +### Interactive Selectors +```go +project, selected, err := cmdData.ProjectSelector(ctx, cmdData) +service, err := uxHelpers.PrintServiceSelector(ctx, restApiClient, projectId) +``` + +### Flags with Shorthand +```go +StringFlag("project-id", "", desc, cmdBuilder.ShortHand("P")) +BoolFlag("verbose", false, desc, cmdBuilder.ShortHand("v")) +``` + +--- + +## Debugging + +### View Debug Logs +```bash +zcli status show-debug-logs +``` + +### Log File Locations +- macOS: `/usr/local/var/log/zerops.log` or `~/.zerops/zerops.log` +- Linux: `/var/log/zerops.log` or `~/.zerops/zerops.log` +- Windows: `%APPDATA%\Zerops\zerops.log` + +### Verbose Mode +```bash +zcli push --verbose +``` + +--- + +## External Resources + +- **Documentation**: https://docs.zerops.io/references/cli +- **Discord**: https://discord.com/invite/WDvCZ54 +- **Support**: https://support.zerops.io +- **API SDK**: https://github.com/zeropsio/zerops-go diff --git a/src/cmd/project.go b/src/cmd/project.go index fc6286a..4f83134 100644 --- a/src/cmd/project.go +++ b/src/cmd/project.go @@ -16,5 +16,7 @@ func projectCmd() *cmdBuilder.Cmd { AddChildrenCmd(projectScopeCmd()). AddChildrenCmd(projectServiceImportCmd()). AddChildrenCmd(projectImportCmd()). - AddChildrenCmd(projectEnvCmd()) + AddChildrenCmd(projectEnvCmd()). + AddChildrenCmd(projectProcessesCmd()). + AddChildrenCmd(projectNotificationsCmd()) } diff --git a/src/cmd/projectNotifications.go b/src/cmd/projectNotifications.go new file mode 100644 index 0000000..4c11711 --- /dev/null +++ b/src/cmd/projectNotifications.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "context" + + "github.com/pkg/errors" + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxHelpers" +) + +func projectNotificationsCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("notifications"). + Short(i18n.T(i18n.CmdDescProjectNotifications)). + Long(i18n.T(i18n.CmdDescProjectNotificationsLong)). + ScopeLevel(cmdBuilder.ScopeProject()). + Arg(cmdBuilder.ProjectArgName, cmdBuilder.OptionalArg()). + IntFlag("limit", 50, i18n.T(i18n.NotificationLimitFlag)). + IntFlag("offset", 0, i18n.T(i18n.NotificationOffsetFlag)). + HelpFlag(i18n.T(i18n.CmdHelpProjectNotifications)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + project, err := cmdData.Project.Expect("project is null") + if err != nil { + return err + } + + limit := cmdData.Params.GetInt("limit") + offset := cmdData.Params.GetInt("offset") + + // Validate limit + if limit < 1 || limit > 100 { + return errors.New(i18n.T(i18n.NotificationLimitInvalid)) + } + + // Validate offset + if offset < 0 { + return errors.New(i18n.T(i18n.NotificationOffsetInvalid)) + } + + return uxHelpers.PrintNotificationList( + ctx, + cmdData.RestApiClient, + cmdData.Stdout, + project.OrgId, + project.Id, + limit, + offset, + ) + }) +} diff --git a/src/cmd/projectProcesses.go b/src/cmd/projectProcesses.go new file mode 100644 index 0000000..dba538f --- /dev/null +++ b/src/cmd/projectProcesses.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "context" + + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxHelpers" +) + +func projectProcessesCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("processes"). + Short(i18n.T(i18n.CmdDescProjectProcesses)). + Long(i18n.T(i18n.CmdDescProjectProcessesLong)). + ScopeLevel(cmdBuilder.ScopeProject()). + Arg(cmdBuilder.ProjectArgName, cmdBuilder.OptionalArg()). + HelpFlag(i18n.T(i18n.CmdHelpProjectProcesses)). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + project, err := cmdData.Project.Expect("project is null") + if err != nil { + return err + } + + return uxHelpers.PrintProcessList( + ctx, + cmdData.RestApiClient, + cmdData.Stdout, + project.OrgId, + project.Id, + ) + }) +} diff --git a/src/entity/process.go b/src/entity/process.go index 62e1ee4..99cabbc 100644 --- a/src/entity/process.go +++ b/src/entity/process.go @@ -7,11 +7,19 @@ import ( ) type Process struct { - Id uuid.ProcessId - OrgId uuid.ClientId - ProjectId uuid.ProjectId - ServiceId uuid.ServiceStackIdNull - ActionName types.String - Status enum.ProcessStatusEnum - Sequence types.Int + Id uuid.ProcessId + OrgId uuid.ClientId + ProjectId uuid.ProjectId + ServiceId uuid.ServiceStackIdNull + ActionName types.String + Status enum.ProcessStatusEnum + Sequence types.Int + Created types.DateTime + LastUpdate types.DateTime + Started types.DateTimeNull + CreatedByUser string + ServiceNames []string + CreatedBySystem types.Bool } + +var ProcessFields = entityTemplateFields[Process]() diff --git a/src/entity/repository/process.go b/src/entity/repository/process.go index deb3d6e..b53ca81 100644 --- a/src/entity/repository/process.go +++ b/src/entity/repository/process.go @@ -2,6 +2,7 @@ package repository import ( "context" + "slices" "github.com/zeropsio/zcli/src/entity" "github.com/zeropsio/zcli/src/gn" @@ -9,9 +10,12 @@ import ( "github.com/zeropsio/zerops-go/dto/input/body" "github.com/zeropsio/zerops-go/dto/output" "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/enum" "github.com/zeropsio/zerops-go/types/uuid" ) +const maxProcessSearchResults = 100 + func GetProcessByActionNameAndProjectId( ctx context.Context, restApiClient *zeropsRestApiClient.Handler, @@ -60,7 +64,6 @@ func processFromEsSearch(esProcess output.EsProcess) entity.Process { } } -//nolint:unused func processFromApiOutput(process output.Process) entity.Process { return entity.Process{ Id: process.Id, @@ -72,3 +75,122 @@ func processFromApiOutput(process output.Process) entity.Process { Sequence: process.Sequence, } } + +// GetRunningAndPendingProcessesByProject fetches RUNNING and PENDING processes +// for a specific project, sorted by creation date descending. +func GetRunningAndPendingProcessesByProject( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, + orgId uuid.ClientId, + projectId uuid.ProjectId, +) ([]entity.Process, error) { + // EsFilter doesn't support OR conditions, so we make two calls and merge. + // Both calls return sorted results, but we re-sort after merge for correctness. + pendingProcesses, err := getProcessesByStatus(ctx, restApiClient, orgId, projectId, enum.ProcessStatusEnumPending) + if err != nil { + return nil, err + } + + runningProcesses, err := getProcessesByStatus(ctx, restApiClient, orgId, projectId, enum.ProcessStatusEnumRunning) + if err != nil { + return nil, err + } + + allProcesses := make([]entity.Process, 0, len(pendingProcesses)+len(runningProcesses)) + allProcesses = append(allProcesses, pendingProcesses...) + allProcesses = append(allProcesses, runningProcesses...) + + slices.SortFunc(allProcesses, func(a, b entity.Process) int { + if a.Created.Native().After(b.Created.Native()) { + return -1 + } + if a.Created.Native().Before(b.Created.Native()) { + return 1 + } + return 0 + }) + + if len(allProcesses) > maxProcessSearchResults { + allProcesses = allProcesses[:maxProcessSearchResults] + } + + return allProcesses, nil +} + +func getProcessesByStatus( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, + orgId uuid.ClientId, + projectId uuid.ProjectId, + status enum.ProcessStatusEnum, +) ([]entity.Process, error) { + esFilter := body.EsFilter{ + Search: body.EsFilterSearch{ + { + Name: "clientId", + Operator: "eq", + Value: orgId.TypedString(), + }, + { + Name: "projectId", + Operator: "eq", + Value: projectId.TypedString(), + }, + { + Name: "status", + Operator: "eq", + Value: types.NewString(status.String()), + }, + }, + Sort: body.EsFilterSort{ + { + Name: "created", + Ascending: types.NewBoolNull(false), + }, + }, + Limit: types.NewIntNull(maxProcessSearchResults), + } + + search, err := restApiClient.PostProcessSearch(ctx, esFilter) + if err != nil { + return nil, err + } + + response, err := search.Output() + if err != nil { + return nil, err + } + + return gn.TransformSlice(response.Items, processFromEsSearchExtended), nil +} + +func processFromEsSearchExtended(esProcess output.EsProcess) entity.Process { + serviceNames := make([]string, 0, len(esProcess.ServiceStacks)) + for _, ss := range esProcess.ServiceStacks { + serviceNames = append(serviceNames, ss.Name.String()) + } + + var createdByUser string + if email, ok := esProcess.CreatedByUser.Email.Get(); ok { + createdByUser = email.Native() + } + if fullName, ok := esProcess.CreatedByUser.FullName.Get(); ok && fullName.Native() != "" { + createdByUser = fullName.Native() + } + + return entity.Process{ + Id: esProcess.Id, + OrgId: esProcess.ClientId, + ProjectId: esProcess.ProjectId, + ServiceId: esProcess.ServiceStackId, + ActionName: esProcess.ActionName, + Status: esProcess.Status, + Sequence: esProcess.Sequence, + Created: esProcess.Created, + LastUpdate: esProcess.LastUpdate, + Started: esProcess.Started, + CreatedByUser: createdByUser, + ServiceNames: serviceNames, + CreatedBySystem: esProcess.CreatedBySystem, + } +} diff --git a/src/entity/repository/service.go b/src/entity/repository/service.go index 6891914..073c79e 100644 --- a/src/entity/repository/service.go +++ b/src/entity/repository/service.go @@ -160,7 +160,7 @@ func PostGenericService( return processFromApiOutput(serviceStackProcess.Process), serviceFromApiPostOutput(serviceStackProcess), nil } func serviceFromEsSearch(esServiceStack output.EsServiceStack) entity.Service { - return entity.Service{ + svc := entity.Service{ Id: esServiceStack.Id, ProjectId: esServiceStack.ProjectId, OrgId: esServiceStack.ClientId, @@ -170,6 +170,11 @@ func serviceFromEsSearch(esServiceStack output.EsServiceStack) entity.Service { ServiceTypeCategory: esServiceStack.ServiceStackTypeInfo.ServiceStackTypeCategory, ServiceStackTypeVersionName: esServiceStack.ServiceStackTypeInfo.ServiceStackTypeVersionName, } + if esServiceStack.ActiveAppVersion != nil { + svc.ActiveAppVersionId = uuid.NewAppVersionIdNull(esServiceStack.ActiveAppVersion.Id) + svc.ActiveAppVersionCreated = types.NewDateTimeNull(esServiceStack.ActiveAppVersion.Created.Native()) + } + return svc } func serviceFromApiOutput(service output.ServiceStack) entity.Service { diff --git a/src/entity/repository/userNotification.go b/src/entity/repository/userNotification.go new file mode 100644 index 0000000..10eea0f --- /dev/null +++ b/src/entity/repository/userNotification.go @@ -0,0 +1,101 @@ +package repository + +import ( + "context" + + "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/gn" + "github.com/zeropsio/zcli/src/zeropsRestApiClient" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/dto/output" + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/uuid" +) + +// GetUserNotificationsByProject fetches notifications for a project with pagination, +// sorted by actionCreated descending. +func GetUserNotificationsByProject( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, + orgId uuid.ClientId, + projectId uuid.ProjectId, + limit int, + offset int, +) ([]entity.UserNotification, error) { + esFilter := body.EsFilter{ + Search: body.EsFilterSearch{ + { + Name: "clientId", + Operator: "eq", + Value: orgId.TypedString(), + }, + { + Name: "project.id", + Operator: "eq", + Value: projectId.TypedString(), + }, + }, + Sort: body.EsFilterSort{ + { + Name: "actionCreated", + Ascending: types.NewBoolNull(false), + }, + }, + Limit: types.NewIntNull(limit), + Offset: types.NewIntNull(offset), + } + + search, err := restApiClient.PostUserNotificationSearch(ctx, esFilter) + if err != nil { + return nil, err + } + + response, err := search.Output() + if err != nil { + return nil, err + } + + return gn.TransformSlice(response.Items, userNotificationFromEsSearch), nil +} + +func userNotificationFromEsSearch(esNotification output.EsUserNotification) entity.UserNotification { + serviceNames := make([]string, 0, len(esNotification.ServiceStacks)) + for _, ss := range esNotification.ServiceStacks { + serviceNames = append(serviceNames, ss.Name.String()) + } + + var createdByUser string + if email, ok := esNotification.CreatedByUser.Email.Get(); ok { + createdByUser = email.Native() + } + if fullName, ok := esNotification.CreatedByUser.FullName.Get(); ok && fullName.Native() != "" { + createdByUser = fullName.Native() + } + + var projectId uuid.ProjectIdNull + var projectName types.StringNull + if esNotification.Project != nil { + projectId = uuid.NewProjectIdNull(esNotification.Project.Id) + projectName = types.NewStringNull(esNotification.Project.Name.String()) + } + + var errorMessage types.StringNull + if esNotification.Error != nil { + errorMessage = types.NewStringNull(esNotification.Error.Message.String()) + } + + return entity.UserNotification{ + Id: esNotification.Id, + OrgId: esNotification.ClientId, + ProjectId: projectId, + ProjectName: projectName, + Type: esNotification.Type, + ActionName: esNotification.ActionName, + ActionCreated: esNotification.ActionCreated, + ActionFinished: esNotification.ActionFinished, + Acknowledged: esNotification.Acknowledged, + CreatedByUser: createdByUser, + ServiceNames: serviceNames, + ErrorMessage: errorMessage, + } +} diff --git a/src/entity/service.go b/src/entity/service.go index ce44edf..ba7cdb8 100644 --- a/src/entity/service.go +++ b/src/entity/service.go @@ -16,6 +16,8 @@ type Service struct { ServiceTypeId stringId.ServiceStackTypeId ServiceTypeCategory enum.ServiceStackTypeCategoryEnum ServiceStackTypeVersionName types.String + ActiveAppVersionId uuid.AppVersionIdNull + ActiveAppVersionCreated types.DateTimeNull } var ServiceFields = entityTemplateFields[Service]() diff --git a/src/entity/userNotification.go b/src/entity/userNotification.go new file mode 100644 index 0000000..84ceef7 --- /dev/null +++ b/src/entity/userNotification.go @@ -0,0 +1,24 @@ +package entity + +import ( + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/enum" + "github.com/zeropsio/zerops-go/types/uuid" +) + +type UserNotification struct { + Id uuid.UserNotificationId + OrgId uuid.ClientId + ProjectId uuid.ProjectIdNull + ProjectName types.StringNull + Type enum.UserNotificationTypeEnum + ActionName types.String + ActionCreated types.DateTime + ActionFinished types.DateTimeNull + Acknowledged types.Bool + CreatedByUser string + ServiceNames []string + ErrorMessage types.StringNull +} + +var UserNotificationFields = entityTemplateFields[UserNotification]() diff --git a/src/i18n/en.go b/src/i18n/en.go index 2fcdbd0..eeabeab 100644 --- a/src/i18n/en.go +++ b/src/i18n/en.go @@ -89,6 +89,22 @@ and your %s.`, CmdDescProjectServiceImport: "Creates one or more Zerops services in an existing project.", ServiceImported: "service(s) imported", + // project processes + CmdHelpProjectProcesses: "Help for the project processes command.", + CmdDescProjectProcesses: "Lists running and pending processes for a project.", + CmdDescProjectProcessesLong: "Lists all currently RUNNING and PENDING processes for a project.\nProcesses represent long-running operations such as deployments, builds, service starts/stops, etc.\nResults are limited to 100 processes, sorted by creation time (newest first).", + ProcessListEmpty: "No running or pending processes found for this project.", + + // project notifications + CmdHelpProjectNotifications: "Help for the project notifications command.", + CmdDescProjectNotifications: "Lists notifications for a project.", + CmdDescProjectNotificationsLong: "Lists notifications for a project. Notifications inform about completed operations,\nwarnings, and errors. Results are sorted by creation time (newest first).\n\nUse --limit and --offset flags for pagination.", + NotificationLimitFlag: "Maximum number of notifications to return (1-100, default: 50).", + NotificationOffsetFlag: "Number of notifications to skip for pagination (default: 0).", + NotificationLimitInvalid: "Invalid --limit value. Allowed interval is <1;100>.", + NotificationOffsetInvalid: "Invalid --offset value. Must be >= 0.", + NotificationListEmpty: "No notifications found for this project.", + // service CmdHelpService: "Help for the service command.", CmdDescService: "Zerops service commands group", @@ -180,8 +196,9 @@ and your %s.`, " use the --working-dir flag to set the working directory to the directory where the zerops.yml file is located.", // service list - CmdHelpServiceList: "Help for the service list command.", - CmdDescServiceList: "Lists all services in the project.", + CmdHelpServiceList: "Help for the service list command.", + CmdDescServiceList: "Lists all services in the project.", + ServiceListProcessesHeader: "Running processes:", // service enable subdomain CmdHelpServiceEnableSubdomain: "the service stop command.", diff --git a/src/i18n/i18n.go b/src/i18n/i18n.go index 9329f03..bd44e2f 100644 --- a/src/i18n/i18n.go +++ b/src/i18n/i18n.go @@ -83,6 +83,22 @@ const ( CmdDescProjectServiceImport = "CmdDescProjectServiceImport" ServiceImported = "ServiceImported" + // project processes + CmdHelpProjectProcesses = "CmdHelpProjectProcesses" + CmdDescProjectProcesses = "CmdDescProjectProcesses" + CmdDescProjectProcessesLong = "CmdDescProjectProcessesLong" + ProcessListEmpty = "ProcessListEmpty" + + // project notifications + CmdHelpProjectNotifications = "CmdHelpProjectNotifications" + CmdDescProjectNotifications = "CmdDescProjectNotifications" + CmdDescProjectNotificationsLong = "CmdDescProjectNotificationsLong" + NotificationLimitFlag = "NotificationLimitFlag" + NotificationOffsetFlag = "NotificationOffsetFlag" + NotificationLimitInvalid = "NotificationLimitInvalid" + NotificationOffsetInvalid = "NotificationOffsetInvalid" + NotificationListEmpty = "NotificationListEmpty" + // service CmdHelpService = "CmdHelpService" CmdDescService = "CmdDescService" @@ -157,8 +173,9 @@ const ( PushDeployZeropsYamlNotFound = "PushDeployZeropsYamlNotFound" // service list - CmdHelpServiceList = "CmdHelpServiceList" - CmdDescServiceList = "CmdDescServiceList" + CmdHelpServiceList = "CmdHelpServiceList" + CmdDescServiceList = "CmdDescServiceList" + ServiceListProcessesHeader = "ServiceListProcessesHeader" // service enable subdomain CmdHelpServiceEnableSubdomain = "CmdHelpServiceEnableSubdomain" diff --git a/src/uxBlock/styles/styles.go b/src/uxBlock/styles/styles.go index f2b202d..e6923f8 100644 --- a/src/uxBlock/styles/styles.go +++ b/src/uxBlock/styles/styles.go @@ -7,6 +7,9 @@ import ( ) const ( + // FORMATS + DateTimeFormat = "2006-01-02 15:04:05" + // SYMBOLS SuccessIcon = "✔" ErrorIcon = "✗" diff --git a/src/uxHelpers/notification.go b/src/uxHelpers/notification.go new file mode 100644 index 0000000..5f5be5e --- /dev/null +++ b/src/uxHelpers/notification.go @@ -0,0 +1,84 @@ +package uxHelpers + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/entity/repository" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxBlock/models/table" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zcli/src/zeropsRestApiClient" + "github.com/zeropsio/zerops-go/types/uuid" +) + +func PrintNotificationList( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, + out io.Writer, + orgId uuid.ClientId, + projectId uuid.ProjectId, + limit int, + offset int, +) error { + notifications, err := repository.GetUserNotificationsByProject( + ctx, + restApiClient, + orgId, + projectId, + limit, + offset, + ) + if err != nil { + return err + } + + if len(notifications) == 0 { + _, err = fmt.Fprintln(out, i18n.T(i18n.NotificationListEmpty)) + return err + } + + header, body := createNotificationTableRows(notifications) + + t := table.Render(body, table.WithHeader(header)) + + _, err = fmt.Fprintln(out, t) + return err +} + +func createNotificationTableRows(notifications []entity.UserNotification) (*table.Row, *table.Body) { + header := table.NewRowFromStrings("id", "action", "type", "services", "created by", "created", "ack") + + body := table.NewBody() + for _, notification := range notifications { + serviceNames := strings.Join(notification.ServiceNames, ", ") + if serviceNames == "" { + serviceNames = "-" + } + + createdBy := notification.CreatedByUser + if createdBy == "" { + createdBy = "-" + } + + ackStatus := "no" + if notification.Acknowledged.Native() { + ackStatus = "yes" + } + + body.AddStringsRow( + string(notification.Id), + notification.ActionName.String(), + notification.Type.String(), + serviceNames, + createdBy, + notification.ActionCreated.Native().Format(styles.DateTimeFormat), + ackStatus, + ) + } + + return header, body +} diff --git a/src/uxHelpers/process.go b/src/uxHelpers/process.go new file mode 100644 index 0000000..16d9888 --- /dev/null +++ b/src/uxHelpers/process.go @@ -0,0 +1,77 @@ +package uxHelpers + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/entity/repository" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxBlock/models/table" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zcli/src/zeropsRestApiClient" + "github.com/zeropsio/zerops-go/types/uuid" +) + +func PrintProcessList( + ctx context.Context, + restApiClient *zeropsRestApiClient.Handler, + out io.Writer, + orgId uuid.ClientId, + projectId uuid.ProjectId, +) error { + processes, err := repository.GetRunningAndPendingProcessesByProject( + ctx, + restApiClient, + orgId, + projectId, + ) + if err != nil { + return err + } + + if len(processes) == 0 { + _, err = fmt.Fprintln(out, i18n.T(i18n.ProcessListEmpty)) + return err + } + + header, body := createProcessTableRows(processes) + + t := table.Render(body, table.WithHeader(header)) + + _, err = fmt.Fprintln(out, t) + return err +} + +func createProcessTableRows(processes []entity.Process) (*table.Row, *table.Body) { + header := table.NewRowFromStrings("id", "action", "status", "services", "created by", "created") + + body := table.NewBody() + for _, process := range processes { + serviceNames := strings.Join(process.ServiceNames, ", ") + if serviceNames == "" { + serviceNames = "-" + } + + createdBy := process.CreatedByUser + if process.CreatedBySystem.Native() { + createdBy = "system" + } + if createdBy == "" { + createdBy = "-" + } + + body.AddStringsRow( + string(process.Id), + process.ActionName.String(), + process.Status.String(), + serviceNames, + createdBy, + process.Created.Native().Format(styles.DateTimeFormat), + ) + } + + return header, body +} diff --git a/src/uxHelpers/service.go b/src/uxHelpers/service.go index bcce733..f2eb364 100644 --- a/src/uxHelpers/service.go +++ b/src/uxHelpers/service.go @@ -86,15 +86,69 @@ func PrintServiceList( t := table.Render(body, table.WithHeader(header)) _, err = fmt.Fprintln(out, t) - return err + if err != nil { + return err + } + + // Fetch running/pending processes for the project + processes, err := repository.GetRunningAndPendingProcessesByProject( + ctx, + restApiClient, + project.OrgId, + project.Id, + ) + if err != nil { + return err + } + + // Only show processes section if there are any + if len(processes) > 0 { + _, err = fmt.Fprintln(out) + if err != nil { + return err + } + _, err = fmt.Fprintln(out, i18n.T(i18n.ServiceListProcessesHeader)) + if err != nil { + return err + } + _, err = fmt.Fprintln(out) + if err != nil { + return err + } + + processHeader, processBody := createProcessTableRows(processes) + pt := table.Render(processBody, table.WithHeader(processHeader)) + _, err = fmt.Fprintln(out, pt) + if err != nil { + return err + } + } + + return nil } -func createServiceTableRows(projects []entity.Service, createNewService bool) (*table.Row, *table.Body) { - header := table.NewRowFromStrings("ID", "Name", "Status") +func createServiceTableRows(services []entity.Service, createNewService bool) (*table.Row, *table.Body) { + header := table.NewRowFromStrings("id", "name", "status", "app version id", "app version created") body := table.NewBody() - for _, project := range projects { - body.AddStringsRow(string(project.Id), project.Name.String(), project.Status.String()) + for _, svc := range services { + appVersionId := "-" + if id, ok := svc.ActiveAppVersionId.Get(); ok { + appVersionId = string(id) + } + + appVersionCreated := "-" + if created, ok := svc.ActiveAppVersionCreated.Get(); ok { + appVersionCreated = created.Native().Format(styles.DateTimeFormat) + } + + body.AddStringsRow( + string(svc.Id), + svc.Name.String(), + svc.Status.String(), + appVersionId, + appVersionCreated, + ) } if createNewService { body.AddCellsRow(