From c4840055a72a3a4265e09a65dc9c11449395079b Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 1 Jul 2026 02:01:59 +0000 Subject: [PATCH 01/14] Remove unused VERSION file Amp-Thread-ID: https://ampcode.com/threads/T-019f1b42-1147-7729-80f6-599df80dd5cd Co-authored-by: Arjun Komath --- VERSION | 1 - 1 file changed, 1 deletion(-) delete mode 100644 VERSION diff --git a/VERSION b/VERSION deleted file mode 100644 index a86d3df7..00000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -v0.18.0 From 176e208a1a4fcb04c17e3d357acfab50ef5358e0 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 1 Jul 2026 19:50:58 +1000 Subject: [PATCH 02/14] Dismiss control plane upgrade dialog on start Amp-Thread-ID: https://ampcode.com/threads/T-019f1d14-eb06-73c7-a8d6-6288b7ef5ad4 Co-authored-by: Amp --- web/components/settings/global-settings.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/components/settings/global-settings.tsx b/web/components/settings/global-settings.tsx index 5efc13a6..f4868709 100644 --- a/web/components/settings/global-settings.tsx +++ b/web/components/settings/global-settings.tsx @@ -133,6 +133,8 @@ export function GlobalSettings({ const [isCheckingUpdates, setIsCheckingUpdates] = useState(false); const [isStartingUpgrade, setIsStartingUpgrade] = useState(false); const [isRefreshingUpgrade, setIsRefreshingUpgrade] = useState(false); + const [controlPlaneUpgradeDialogOpen, setControlPlaneUpgradeDialogOpen] = + useState(false); useEffect(() => { const openedAbout = tab === "about" && previousTabRef.current !== "about"; @@ -260,6 +262,7 @@ export function GlobalSettings({ try { await upgradeControlPlane(targetVersion); toast.success("Control plane upgrade started"); + setControlPlaneUpgradeDialogOpen(false); router.refresh(); } catch (error) { toast.error( @@ -669,7 +672,10 @@ export function GlobalSettings({ )} {updateState?.updateAvailable && updateState.latestVersion && ( - + From 196a02334a0689ac17aa88c57b2c22e6a50c1d64 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 1 Jul 2026 20:03:35 +1000 Subject: [PATCH 03/14] Add favicon metadata Amp-Thread-ID: https://ampcode.com/threads/T-019f1d14-eb06-73c7-a8d6-6288b7ef5ad4 Co-authored-by: Amp --- web/app/layout.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/web/app/layout.tsx b/web/app/layout.tsx index d9e029e1..4a3f612d 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -19,6 +19,13 @@ const ioskeleyMono = localFont({ export const metadata: Metadata = { title: "Techulus Cloud", description: "Stateless container deployment platform", + icons: { + icon: [ + { url: "/favicon.ico" }, + { url: "/favicon-16x16.png", sizes: "16x16", type: "image/png" }, + { url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" }, + ], + }, }; export const viewport: Viewport = { From 97d46388c16dff0bab13718936e35aa9b6b32f60 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 1 Jul 2026 20:59:33 +1000 Subject: [PATCH 04/14] Show member invite validation errors Amp-Thread-ID: https://ampcode.com/threads/T-019f1d14-eb06-73c7-a8d6-6288b7ef5ad4 Co-authored-by: Amp --- web/actions/members.ts | 22 +++++++++++++++------ web/components/settings/member-settings.tsx | 5 +++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/web/actions/members.ts b/web/actions/members.ts index 508d3fd6..c2374375 100644 --- a/web/actions/members.ts +++ b/web/actions/members.ts @@ -139,8 +139,15 @@ export async function inviteMember(input: { }) { const session = await requireAdminSession(); - const parsed = inviteMemberSchema.parse(input); - const email = parsed.email.toLowerCase(); + const parsed = inviteMemberSchema.safeParse(input); + if (!parsed.success) { + return { + success: false as const, + error: parsed.error.issues[0]?.message ?? "Invalid invitation details", + }; + } + + const email = parsed.data.email.toLowerCase(); const existingUser = await db .select({ id: user.id }) @@ -149,7 +156,10 @@ export async function inviteMember(input: { .limit(1); if (existingUser.length > 0) { - throw new Error("A member with this email already exists"); + return { + success: false as const, + error: "A member with this email already exists", + }; } await db @@ -186,7 +196,7 @@ export async function inviteMember(input: { await db.insert(memberInvitations).values({ id: randomUUID(), email, - role: parsed.role, + role: parsed.data.role, tokenHash: hashInviteToken(token), status: "pending", invitedByUserId: session.user.id, @@ -196,12 +206,12 @@ export async function inviteMember(input: { const emailSent = await sendMemberInviteEmail({ to: email, inviterName: session.user.name, - role: parsed.role, + role: parsed.data.role, inviteUrl, }); revalidatePath("/dashboard/settings"); - return { success: true, inviteUrl, emailSent }; + return { success: true as const, inviteUrl, emailSent }; } export async function revokeInvitation(invitationId: string) { diff --git a/web/components/settings/member-settings.tsx b/web/components/settings/member-settings.tsx index 90cb0865..61a9295f 100644 --- a/web/components/settings/member-settings.tsx +++ b/web/components/settings/member-settings.tsx @@ -78,6 +78,11 @@ export function MemberSettings({ initialMembers, initialInvitations }: Props) { setIsInviting(true); try { const result = await inviteMember({ email, role }); + if (!result.success) { + toast.error(result.error); + return; + } + setEmail(""); toast.success( result.emailSent ? "Invitation sent" : "Invitation created", From 604615bb30fd030dc833fd9a1cf8d9ee2119e3d4 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:24:30 +1000 Subject: [PATCH 05/14] add agent output mode --- cli/cmd/tc/main.go | 4 +- cli/internal/cli/app.go | 323 ++++++++++++++++++++++++++++++++-- cli/internal/cli/app_test.go | 269 ++++++++++++++++++++++++++++ cli/internal/cli/types.go | 27 +++ cli/internal/output/output.go | 29 +++ 5 files changed, 641 insertions(+), 11 deletions(-) diff --git a/cli/cmd/tc/main.go b/cli/cmd/tc/main.go index 6814ec0c..58a8a0f1 100644 --- a/cli/cmd/tc/main.go +++ b/cli/cmd/tc/main.go @@ -11,7 +11,9 @@ var version = "dev" func main() { if err := cli.Execute(version, os.Stdin, os.Stdout, os.Stderr); err != nil { - fmt.Fprintln(os.Stderr, err) + if !cli.IsHandledError(err) { + fmt.Fprintln(os.Stderr, err) + } os.Exit(1) } } diff --git a/cli/internal/cli/app.go b/cli/internal/cli/app.go index f18d7b51..8f88161b 100644 --- a/cli/internal/cli/app.go +++ b/cli/internal/cli/app.go @@ -16,6 +16,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/spf13/pflag" "techulus/cloud-cli/internal/api" "techulus/cloud-cli/internal/auth" @@ -32,6 +33,7 @@ const ( type App struct { Version string + Args []string In io.Reader Out io.Writer Err io.Writer @@ -40,6 +42,29 @@ type App struct { Now func() time.Time IsInteractive func() bool GetCWD func() (string, error) + flags globalFlags +} + +type globalFlags struct { + Agent bool + JSON bool +} + +type handledError struct { + err error +} + +func (e handledError) Error() string { + return e.err.Error() +} + +func (e handledError) Unwrap() error { + return e.err +} + +func IsHandledError(err error) bool { + var handled handledError + return errors.As(err, &handled) } func Execute(version string, in io.Reader, out io.Writer, errOut io.Writer) error { @@ -82,7 +107,17 @@ func (a *App) Execute() error { cmd.SetIn(a.In) cmd.SetOut(a.Out) cmd.SetErr(a.Err) - return cmd.Execute() + if a.Args != nil { + cmd.SetArgs(a.Args) + } + if err := cmd.Execute(); err != nil { + if a.isMachineOutput() { + _ = a.writeError(err) + return handledError{err: err} + } + return err + } + return nil } func (a *App) rootCommand() *cobra.Command { @@ -91,8 +126,22 @@ func (a *App) rootCommand() *cobra.Command { Short: "Techulus Cloud CLI", SilenceUsage: true, SilenceErrors: true, + Annotations: map[string]string{ + "agent_notes": "Use --help --agent on any command for structured command metadata.\nUse --agent for raw JSON data on success; failures are returned as {\"ok\":false,\"error\":\"...\"}.\nUse --json for an ok/data envelope.", + }, } + root.PersistentFlags().BoolVar(&a.flags.Agent, "agent", false, "Agent mode: raw JSON data on success and structured JSON errors") + root.PersistentFlags().BoolVar(&a.flags.JSON, "json", false, "Output an ok/data JSON envelope") + defaultHelp := root.HelpFunc() + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if a.shouldEmitAgentHelp(cmd) { + _ = a.writeRaw(agentHelpForCommand(cmd)) + return + } + defaultHelp(cmd, args) + }) + root.AddCommand(a.authCommand()) root.AddCommand(a.initCommand()) root.AddCommand(a.linkCommand()) @@ -110,6 +159,9 @@ func (a *App) authCommand() *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "Manage CLI authentication", + Annotations: map[string]string{ + "agent_notes": "Most commands require an existing login. Use auth whoami to inspect the saved session.", + }, } cmd.AddCommand(a.authLoginCommand()) cmd.AddCommand(a.authLogoutCommand()) @@ -122,7 +174,13 @@ func (a *App) authLoginCommand() *cobra.Command { cmd := &cobra.Command{ Use: "login", Short: "Sign in with device login", + Annotations: map[string]string{ + "agent_notes": "Device login requires browser approval and is not fully non-interactive.", + }, RunE: func(cmd *cobra.Command, args []string) error { + if a.isMachineOutput() { + return errors.New("tc auth login requires human browser approval and does not support --agent or --json") + } if host == "" { existing, err := auth.ReadConfig() if err != nil { @@ -150,6 +208,9 @@ func (a *App) authLogoutCommand() *cobra.Command { if err := auth.DeleteConfig(); err != nil { return err } + if a.isMachineOutput() { + return a.writeData(map[string]string{"config": "removed"}, "Signed out") + } output.Section(a.Out, "Signed out") output.Field(a.Out, "Config", "removed") return nil @@ -173,10 +234,17 @@ func (a *App) authWhoamiCommand() *cobra.Command { if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/cli/auth/whoami", nil, nil, &response); err != nil { return err } + result := authWhoamiOutput{ + User: response.User, + Host: config.Host, + } + if a.isMachineOutput() { + return a.writeData(result, "Account") + } output.Section(a.Out, "Account") - output.Field(a.Out, "User", response.User.Email) - output.Field(a.Out, "Name", response.User.Name) - output.Field(a.Out, "Host", config.Host) + output.Field(a.Out, "User", result.User.Email) + output.Field(a.Out, "Name", result.User.Name) + output.Field(a.Out, "Host", result.Host) return nil }, } @@ -186,6 +254,9 @@ func (a *App) initCommand() *cobra.Command { return &cobra.Command{ Use: "init", Short: "Create a starter techulus.yml", + Annotations: map[string]string{ + "agent_notes": "Creates techulus.yml in the current working directory. Fails if techulus.yml already exists.", + }, RunE: func(cmd *cobra.Command, args []string) error { cwd, err := a.GetCWD() if err != nil { @@ -222,6 +293,9 @@ service: if err := os.WriteFile(manifestPath, []byte(starter), 0o644); err != nil { return err } + if a.isMachineOutput() { + return a.writeData(initOutput{Manifest: manifestPath, Next: "tc apply"}, "Manifest created") + } output.Section(a.Out, "Manifest") output.Field(a.Out, "Created", manifestPath) output.Next(a.Out, "tc apply") @@ -235,7 +309,13 @@ func (a *App) linkCommand() *cobra.Command { cmd := &cobra.Command{ Use: "link", Short: "Create techulus.yml from an existing service", + Annotations: map[string]string{ + "agent_notes": "Requires an interactive terminal and does not support --agent or --json. Agents should usually pass --project, --environment, and --service to status/logs instead of linking.", + }, RunE: func(cmd *cobra.Command, args []string) error { + if a.isMachineOutput() { + return errors.New("tc link requires an interactive terminal and does not support --agent or --json") + } if !a.IsInteractive() { return errors.New("tc link requires an interactive terminal") } @@ -303,6 +383,9 @@ func (a *App) applyCommand() *cobra.Command { return &cobra.Command{ Use: "apply", Short: "Apply techulus.yml to the linked service", + Annotations: map[string]string{ + "agent_notes": "Requires techulus.yml in the current directory and sends the full desired manifest to the control plane.", + }, RunE: func(cmd *cobra.Command, args []string) error { config, err := requireConfig() if err != nil { @@ -317,6 +400,9 @@ func (a *App) applyCommand() *cobra.Command { if err := client.RequestJSON(cmd.Context(), http.MethodPost, "/api/v1/manifest/apply", nil, loaded.Manifest, &result); err != nil { return err } + if a.isMachineOutput() { + return a.writeData(result, "Apply") + } printApplyResult(a.Out, result) return nil }, @@ -327,6 +413,9 @@ func (a *App) deployCommand() *cobra.Command { return &cobra.Command{ Use: "deploy", Short: "Deploy the service described by techulus.yml", + Annotations: map[string]string{ + "agent_notes": "Requires techulus.yml in the current directory and queues a deployment for that service.", + }, RunE: func(cmd *cobra.Command, args []string) error { config, err := requireConfig() if err != nil { @@ -341,6 +430,9 @@ func (a *App) deployCommand() *cobra.Command { if err := client.RequestJSON(cmd.Context(), http.MethodPost, "/api/v1/manifest/deploy", nil, loaded.Manifest, &result); err != nil { return err } + if a.isMachineOutput() { + return a.writeData(result, "Deploy") + } output.Section(a.Out, "Deploy") output.Field(a.Out, "Service", output.ShortID(result.ServiceID)) output.Field(a.Out, "Status", output.Status(result.Status)) @@ -354,36 +446,53 @@ func (a *App) deployCommand() *cobra.Command { } func (a *App) statusCommand() *cobra.Command { - return &cobra.Command{ + var target serviceTargetFlags + cmd := &cobra.Command{ Use: "status", Short: "Show service rollout and deployment status", + Annotations: map[string]string{ + "agent_notes": "Without explicit target flags, tc reads techulus.yml from the current directory.\nFor agent use outside a linked directory, pass --project, --environment, and --service together.", + }, RunE: func(cmd *cobra.Command, args []string) error { config, err := requireConfig() if err != nil { return err } - loaded, err := a.ensureManifest() + value, err := a.resolveServiceTarget(target) if err != nil { return err } var status statusResponse client := a.client(config) - query := manifestIdentityQuery(loaded.Manifest) + query := manifestIdentityQuery(value) if err := client.RequestJSON(cmd.Context(), http.MethodGet, "/api/v1/manifest/status", query, nil, &status); err != nil { return err } - printStatus(a.Out, loaded.Manifest, status) + result := statusOutput{ + Target: serviceTargetFromManifest(value), + Status: status, + } + if a.isMachineOutput() { + return a.writeData(result, "Status") + } + printStatus(a.Out, value, status) return nil }, } + addServiceTargetFlags(cmd, &target) + return cmd } func (a *App) logsCommand() *cobra.Command { var tail int var follow bool + var target serviceTargetFlags cmd := &cobra.Command{ Use: "logs", Short: "Show service logs", + Annotations: map[string]string{ + "agent_notes": "Without explicit target flags, tc reads techulus.yml from the current directory.\nFor agent use outside a linked directory, pass --project, --environment, and --service together.\nIn --agent or --json mode, logs are one-shot JSON output; --follow=true is not supported.", + }, RunE: func(cmd *cobra.Command, args []string) error { if tail < 1 || tail > 1000 { return errors.New("log line count must be between 1 and 1000") @@ -392,19 +501,26 @@ func (a *App) logsCommand() *cobra.Command { if tailChanged && !cmd.Flags().Changed("follow") { follow = false } + if a.isMachineOutput() { + if cmd.Flags().Changed("follow") && follow { + return errors.New("--follow=true is not supported with --agent or --json") + } + follow = false + } config, err := requireConfig() if err != nil { return err } - loaded, err := a.ensureManifest() + value, err := a.resolveServiceTarget(target) if err != nil { return err } - return a.runLogs(cmd.Context(), config, loaded.Manifest, tail, follow) + return a.runLogs(cmd.Context(), config, value, tail, follow) }, } cmd.Flags().IntVarP(&tail, "tail", "n", defaultLogTail, "Number of log lines to fetch") cmd.Flags().BoolVar(&follow, "follow", true, "Continue polling for new log lines") + addServiceTargetFlags(cmd, &target) return cmd } @@ -413,6 +529,9 @@ func (a *App) versionCommand() *cobra.Command { Use: "version", Short: "Print the tc version", RunE: func(cmd *cobra.Command, args []string) error { + if a.isMachineOutput() { + return a.writeData(map[string]string{"version": a.Version}, "Version") + } fmt.Fprintln(a.Out, a.Version) return nil }, @@ -463,6 +582,183 @@ func (a *App) ensureManifest() (*manifest.Loaded, error) { return nil, fmt.Errorf("invalid techulus.yml: %w", err) } +func (a *App) isMachineOutput() bool { + return a.flags.Agent || a.flags.JSON +} + +func (a *App) writeData(data any, summary string) error { + if a.flags.Agent { + return a.writeRaw(data) + } + return output.OK(a.Out, data, summary) +} + +func (a *App) writeRaw(data any) error { + return output.JSON(a.Out, data) +} + +func (a *App) writeError(err error) error { + return output.Error(a.Out, err) +} + +func (a *App) shouldEmitAgentHelp(cmd *cobra.Command) bool { + root := cmd.Root() + if root == nil { + root = cmd + } + agent, _ := root.PersistentFlags().GetBool("agent") + jsonOutput, _ := root.PersistentFlags().GetBool("json") + return agent || jsonOutput +} + +type agentHelpInfo struct { + Command string `json:"command"` + Path string `json:"path"` + Short string `json:"short"` + Long string `json:"long,omitempty"` + Usage string `json:"usage"` + Notes []string `json:"notes,omitempty"` + Args []agentArg `json:"args,omitempty"` + Subcommands []agentSubcommand `json:"subcommands,omitempty"` + Flags []agentFlag `json:"flags,omitempty"` + InheritedFlags []agentFlag `json:"inherited_flags,omitempty"` +} + +type agentArg struct { + Name string `json:"name"` + Required bool `json:"required"` +} + +type agentSubcommand struct { + Name string `json:"name"` + Short string `json:"short"` + Path string `json:"path"` +} + +type agentFlag struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Type string `json:"type"` + Default string `json:"default"` + Usage string `json:"usage"` +} + +func agentHelpForCommand(cmd *cobra.Command) agentHelpInfo { + info := agentHelpInfo{ + Command: cmd.Name(), + Path: cmd.CommandPath(), + Short: cmd.Short, + Long: cmd.Long, + Usage: cmd.UseLine(), + Args: parseAgentArgs(cmd), + } + if notes := strings.TrimSpace(cmd.Annotations["agent_notes"]); notes != "" { + for _, note := range strings.Split(notes, "\n") { + note = strings.TrimSpace(note) + if note != "" { + info.Notes = append(info.Notes, note) + } + } + } + for _, sub := range cmd.Commands() { + if sub.IsAvailableCommand() || sub.Name() == "help" { + info.Subcommands = append(info.Subcommands, agentSubcommand{ + Name: sub.Name(), + Short: sub.Short, + Path: sub.CommandPath(), + }) + } + } + cmd.NonInheritedFlags().VisitAll(func(flag *pflag.Flag) { + if flag.Name != "help" { + info.Flags = append(info.Flags, agentFlagFor(flag)) + } + }) + cmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) { + if flag.Name != "help" { + info.InheritedFlags = append(info.InheritedFlags, agentFlagFor(flag)) + } + }) + return info +} + +func agentFlagFor(flag *pflag.Flag) agentFlag { + return agentFlag{ + Name: flag.Name, + Shorthand: flag.Shorthand, + Type: flag.Value.Type(), + Default: flag.DefValue, + Usage: flag.Usage, + } +} + +func parseAgentArgs(cmd *cobra.Command) []agentArg { + fields := strings.Fields(cmd.Use) + if len(fields) <= 1 { + return nil + } + args := make([]agentArg, 0, len(fields)-1) + for _, field := range fields[1:] { + required := strings.HasPrefix(field, "<") || (!strings.HasPrefix(field, "[") && !strings.HasSuffix(field, "]")) + name := strings.Trim(field, "[]<>") + if name == "" || name == "flags" { + continue + } + args = append(args, agentArg{Name: name, Required: required}) + } + return args +} + +type serviceTargetFlags struct { + Project string + Environment string + Service string +} + +func addServiceTargetFlags(cmd *cobra.Command, target *serviceTargetFlags) { + cmd.Flags().StringVar(&target.Project, "project", "", "Project name or slug") + cmd.Flags().StringVar(&target.Environment, "environment", "", "Environment name") + cmd.Flags().StringVar(&target.Service, "service", "", "Service name") +} + +func (a *App) resolveServiceTarget(target serviceTargetFlags) (manifest.Manifest, error) { + project := strings.TrimSpace(target.Project) + environment := strings.TrimSpace(target.Environment) + service := strings.TrimSpace(target.Service) + explicitCount := 0 + for _, value := range []string{project, environment, service} { + if value != "" { + explicitCount++ + } + } + if explicitCount == 0 { + loaded, err := a.ensureManifest() + if err != nil { + return manifest.Manifest{}, err + } + return loaded.Manifest, nil + } + if explicitCount != 3 { + return manifest.Manifest{}, errors.New("provide --project, --environment, and --service together") + } + return manifest.Manifest{ + APIVersion: "v1", + Project: project, + Environment: environment, + Service: manifest.Service{ + Name: service, + }, + }, nil +} + +func serviceTargetFromManifest(value manifest.Manifest) serviceTargetOutput { + return serviceTargetOutput{ + Project: value.Project, + Environment: value.Environment, + Service: value.Service.Name, + } +} + func requireConfig() (*auth.Config, error) { config, err := auth.ReadConfig() if err != nil { @@ -585,6 +881,13 @@ func (a *App) runLogs(ctx context.Context, config *auth.Config, value manifest.M if err != nil { return err } + if a.isMachineOutput() { + return a.writeData(logsOutput{ + Target: serviceTargetFromManifest(value), + LoggingEnabled: result.LoggingEnabled, + Logs: result.Logs, + }, "Logs") + } fmt.Fprintf(a.Out, "%s/%s/%s\n", value.Project, value.Environment, value.Service.Name) if !result.LoggingEnabled { output.Section(a.Out, "Logs") diff --git a/cli/internal/cli/app_test.go b/cli/internal/cli/app_test.go index d79cd4c4..d88a7dac 100644 --- a/cli/internal/cli/app_test.go +++ b/cli/internal/cli/app_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -41,6 +42,251 @@ func TestLogsRejectsInvalidTailBeforeConfig(t *testing.T) { } } +func TestAgentHelpOutputsStructuredCommandMetadata(t *testing.T) { + stdout, stderr, err := runTestCommand(t, nil, t.TempDir(), "status", "--help", "--agent") + if err != nil { + t.Fatalf("help error = %v\nstderr=%s", err, stderr) + } + var help agentHelpInfo + if err := json.Unmarshal([]byte(stdout), &help); err != nil { + t.Fatalf("decode help: %v\nstdout=%s", err, stdout) + } + if help.Command != "status" || help.Path != "tc status" { + t.Fatalf("help = %#v", help) + } + if !agentFlagsContain(help.Flags, "project") || !agentFlagsContain(help.InheritedFlags, "agent") { + t.Fatalf("flags = %#v inherited = %#v", help.Flags, help.InheritedFlags) + } + if len(help.Notes) == 0 || !strings.Contains(strings.Join(help.Notes, "\n"), "explicit target flags") { + t.Fatalf("notes = %#v", help.Notes) + } +} + +func TestAgentStatusOutputsRawJSON(t *testing.T) { + tmp := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/status" { + t.Fatalf("path = %s", r.URL.Path) + } + w.Write([]byte(`{"service":{"id":"1234567890abcdef","name":"web","image":"nginx:1.27","hostname":null,"replicas":1},"latestRollout":null,"deployments":[]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "--agent", "status", "--project", "app", "--environment", "production", "--service", "web") + if err != nil { + t.Fatalf("status error = %v\nstderr=%s", err, stderr) + } + var raw map[string]any + if err := json.Unmarshal([]byte(stdout), &raw); err != nil { + t.Fatalf("decode raw: %v\nstdout=%s", err, stdout) + } + if _, ok := raw["ok"]; ok { + t.Fatalf("agent output should be raw data, got %s", stdout) + } + target := raw["target"].(map[string]any) + if target["project"] != "app" || raw["status"] == nil { + t.Fatalf("raw = %#v", raw) + } +} + +func TestJSONStatusOutputsEnvelope(t *testing.T) { + tmp := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/status" { + t.Fatalf("path = %s", r.URL.Path) + } + w.Write([]byte(`{"service":{"id":"1234567890abcdef","name":"web","image":"nginx:1.27","hostname":null,"replicas":1},"latestRollout":null,"deployments":[]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "--json", "status", "--project", "app", "--environment", "production", "--service", "web") + if err != nil { + t.Fatalf("status error = %v\nstderr=%s", err, stderr) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + } + if envelope["ok"] != true || envelope["data"] == nil || envelope["summary"] != "Status" { + t.Fatalf("envelope = %#v", envelope) + } +} + +func TestAgentLogsOutputsOneShotJSON(t *testing.T) { + tmp := t.TempDir() + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/logs" { + t.Fatalf("path = %s", r.URL.Path) + } + requests++ + w.Write([]byte(`{"loggingEnabled":true,"logs":[{"deploymentId":"d","stream":"stdout","message":"hello","timestamp":"2026-01-01T00:00:00Z"}]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "--agent", "logs", "--project", "app", "--environment", "production", "--service", "web") + if err != nil { + t.Fatalf("logs error = %v\nstderr=%s", err, stderr) + } + var result logsOutput + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("decode logs: %v\nstdout=%s", err, stdout) + } + if requests != 1 || !result.LoggingEnabled || len(result.Logs) != 1 || result.Logs[0].Message != "hello" { + t.Fatalf("requests=%d result=%#v", requests, result) + } +} + +func TestAgentLogsRejectsFollowTrue(t *testing.T) { + _, _, err := runTestCommand(t, nil, t.TempDir(), "--agent", "logs", "--project", "app", "--environment", "production", "--service", "web", "--follow=true") + if err == nil || !strings.Contains(err.Error(), "--follow=true is not supported") { + t.Fatalf("error = %v", err) + } +} + +func TestExecuteWritesMachineErrorEnvelope(t *testing.T) { + tmp := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected API request: %s", r.URL.Path) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestAppExecute(t, server.Client(), tmp, "--json", "status", "--project", "app") + if err == nil { + t.Fatal("expected error") + } + if !IsHandledError(err) { + t.Fatalf("error should be marked handled, got %T %v", err, err) + } + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + } + if envelope["ok"] != false || !strings.Contains(envelope["error"].(string), "provide --project") { + t.Fatalf("envelope = %#v", envelope) + } +} + +func TestHandledErrorUnwrapsOriginalError(t *testing.T) { + base := errors.New("base") + wrapped := handledError{err: base} + if !errors.Is(wrapped, base) { + t.Fatalf("handledError should unwrap original error") + } +} + +func TestAuthLoginRejectsMachineOutputBeforeWritingHumanText(t *testing.T) { + stdout, stderr, err := runTestAppExecute(t, nil, t.TempDir(), "--agent", "auth", "login", "--host", "https://example.com") + if err == nil { + t.Fatal("expected error") + } + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + } + if envelope["ok"] != false || !strings.Contains(envelope["error"].(string), "requires human browser approval") { + t.Fatalf("envelope = %#v", envelope) + } +} + +func TestLinkRejectsMachineOutputWithSpecificMessage(t *testing.T) { + stdout, stderr, err := runTestAppExecute(t, nil, t.TempDir(), "--json", "link") + if err == nil { + t.Fatal("expected error") + } + if stderr != "" { + t.Fatalf("stderr = %q", stderr) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + } + if envelope["ok"] != false || !strings.Contains(envelope["error"].(string), "does not support --agent or --json") { + t.Fatalf("envelope = %#v", envelope) + } +} + +func TestStatusUsesExplicitTargetWithoutManifest(t *testing.T) { + tmp := t.TempDir() + var sawStatus bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/status" { + t.Fatalf("path = %s", r.URL.Path) + } + query := r.URL.Query() + if query.Get("project") != "app" || query.Get("environment") != "production" || query.Get("service") != "web" { + t.Fatalf("query = %s", r.URL.RawQuery) + } + sawStatus = true + w.Write([]byte(`{"service":{"id":"1234567890abcdef","name":"web","image":"nginx:1.27","hostname":null,"replicas":1},"latestRollout":null,"deployments":[]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "status", "--project", "app", "--environment", "production", "--service", "web") + if err != nil { + t.Fatalf("status error = %v\nstderr=%s", err, stderr) + } + if !sawStatus || !strings.Contains(stdout, "app/production/web") || !strings.Contains(stdout, "nginx:1.27") { + t.Fatalf("stdout = %s sawStatus=%v", stdout, sawStatus) + } +} + +func TestLogsUsesExplicitTargetWithoutManifest(t *testing.T) { + tmp := t.TempDir() + var sawLogs bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/manifest/logs" { + t.Fatalf("path = %s", r.URL.Path) + } + query := r.URL.Query() + if query.Get("project") != "app" || query.Get("environment") != "production" || query.Get("service") != "web" || query.Get("tail") != "10" { + t.Fatalf("query = %s", r.URL.RawQuery) + } + sawLogs = true + w.Write([]byte(`{"loggingEnabled":true,"logs":[{"deploymentId":"d","stream":"stdout","message":"hello","timestamp":"2026-01-01T00:00:00Z"}]}`)) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + stdout, stderr, err := runTestCommand(t, server.Client(), tmp, "logs", "--project", "app", "--environment", "production", "--service", "web", "--tail", "10", "--follow=false") + if err != nil { + t.Fatalf("logs error = %v\nstderr=%s", err, stderr) + } + if !sawLogs || !strings.Contains(stdout, "app/production/web") || !strings.Contains(stdout, "hello") { + t.Fatalf("stdout = %s sawLogs=%v", stdout, sawLogs) + } +} + +func TestReadOnlyTargetsRejectPartialFlags(t *testing.T) { + tmp := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected API request: %s", r.URL.Path) + })) + defer server.Close() + writeTestConfig(t, server.URL) + + _, _, err := runTestCommand(t, server.Client(), tmp, "status", "--project", "app") + if err == nil || !strings.Contains(err.Error(), "provide --project, --environment, and --service together") { + t.Fatalf("status error = %v", err) + } + + _, _, err = runTestCommand(t, server.Client(), tmp, "logs", "--project", "app", "--service", "web", "--follow=false") + if err == nil || !strings.Contains(err.Error(), "provide --project, --environment, and --service together") { + t.Fatalf("logs error = %v", err) + } +} + func TestApplyPostsManifest(t *testing.T) { tmp := t.TempDir() writeTestManifest(t, tmp) @@ -253,6 +499,20 @@ func runTestCommandWithInput(t *testing.T, client *http.Client, cwd string, stdi return stdout.String(), stderr.String(), err } +func runTestAppExecute(t *testing.T, client *http.Client, cwd string, args ...string) (string, string, error) { + t.Helper() + var stdout bytes.Buffer + var stderr bytes.Buffer + app := NewApp("test", strings.NewReader(""), &stdout, &stderr) + app.Args = args + if client != nil { + app.HTTPClient = client + } + app.GetCWD = func() (string, error) { return cwd, nil } + err := app.Execute() + return stdout.String(), stderr.String(), err +} + func writeTestManifest(t *testing.T, dir string) { t.Helper() raw := `apiVersion: v1 @@ -293,3 +553,12 @@ func writeTestConfig(t *testing.T, host string) { t.Fatalf("WriteConfig() error = %v", err) } } + +func agentFlagsContain(flags []agentFlag, name string) bool { + for _, flag := range flags { + if flag.Name == name { + return true + } + } + return false +} diff --git a/cli/internal/cli/types.go b/cli/internal/cli/types.go index f323ff54..c92fcfe0 100644 --- a/cli/internal/cli/types.go +++ b/cli/internal/cli/types.go @@ -29,6 +29,16 @@ type exchangeResponse struct { User auth.User `json:"user"` } +type authWhoamiOutput struct { + User auth.User `json:"user"` + Host string `json:"host"` +} + +type initOutput struct { + Manifest string `json:"manifest"` + Next string `json:"next"` +} + type linkServiceTarget struct { ID string `json:"id"` Name string `json:"name"` @@ -115,6 +125,23 @@ type logsResponse struct { Logs []serviceLog `json:"logs"` } +type serviceTargetOutput struct { + Project string `json:"project"` + Environment string `json:"environment"` + Service string `json:"service"` +} + +type statusOutput struct { + Target serviceTargetOutput `json:"target"` + Status statusResponse `json:"status"` +} + +type logsOutput struct { + Target serviceTargetOutput `json:"target"` + LoggingEnabled bool `json:"loggingEnabled"` + Logs []serviceLog `json:"logs"` +} + func countSupportedServices(projects []linkProjectTarget) int { total := 0 for _, project := range projects { diff --git a/cli/internal/output/output.go b/cli/internal/output/output.go index c0589b24..2a49d93d 100644 --- a/cli/internal/output/output.go +++ b/cli/internal/output/output.go @@ -1,12 +1,41 @@ package output import ( + "encoding/json" "fmt" "io" "strings" "time" ) +type Envelope struct { + OK bool `json:"ok"` + Data any `json:"data,omitempty"` + Summary string `json:"summary,omitempty"` +} + +type ErrorEnvelope struct { + OK bool `json:"ok"` + Error string `json:"error"` +} + +func JSON(w io.Writer, value any) error { + encoder := json.NewEncoder(w) + return encoder.Encode(value) +} + +func OK(w io.Writer, data any, summary string) error { + return JSON(w, Envelope{OK: true, Data: data, Summary: summary}) +} + +func Error(w io.Writer, err error) error { + message := "" + if err != nil { + message = err.Error() + } + return JSON(w, ErrorEnvelope{OK: false, Error: message}) +} + func Section(w io.Writer, title string) { fmt.Fprintf(w, "\n%s\n%s\n", title, strings.Repeat("-", len(title))) } From dacd1fd4ecc76ed31e1997bbdffe7d1f1c0a879a Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:44:54 +1000 Subject: [PATCH 06/14] Address agent help review notes --- cli/internal/cli/app.go | 53 ++++++++++++++++++++++++++---------- cli/internal/cli/app_test.go | 43 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/cli/internal/cli/app.go b/cli/internal/cli/app.go index 8f88161b..14a69ef2 100644 --- a/cli/internal/cli/app.go +++ b/cli/internal/cli/app.go @@ -135,10 +135,14 @@ func (a *App) rootCommand() *cobra.Command { root.PersistentFlags().BoolVar(&a.flags.JSON, "json", false, "Output an ok/data JSON envelope") defaultHelp := root.HelpFunc() root.SetHelpFunc(func(cmd *cobra.Command, args []string) { - if a.shouldEmitAgentHelp(cmd) { + if a.flags.Agent { _ = a.writeRaw(agentHelpForCommand(cmd)) return } + if a.flags.JSON { + _ = a.writeData(agentHelpForCommand(cmd), "Help") + return + } defaultHelp(cmd, args) }) @@ -540,7 +544,7 @@ func (a *App) versionCommand() *cobra.Command { func (a *App) completionCommand(root *cobra.Command) *cobra.Command { cmd := &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", + Use: "completion ", Short: "Generate shell completion scripts", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -601,16 +605,6 @@ func (a *App) writeError(err error) error { return output.Error(a.Out, err) } -func (a *App) shouldEmitAgentHelp(cmd *cobra.Command) bool { - root := cmd.Root() - if root == nil { - root = cmd - } - agent, _ := root.PersistentFlags().GetBool("agent") - jsonOutput, _ := root.PersistentFlags().GetBool("json") - return agent || jsonOutput -} - type agentHelpInfo struct { Command string `json:"command"` Path string `json:"path"` @@ -625,8 +619,9 @@ type agentHelpInfo struct { } type agentArg struct { - Name string `json:"name"` - Required bool `json:"required"` + Name string `json:"name"` + Required bool `json:"required"` + Choices []string `json:"choices,omitempty"` } type agentSubcommand struct { @@ -704,11 +699,39 @@ func parseAgentArgs(cmd *cobra.Command) []agentArg { if name == "" || name == "flags" { continue } - args = append(args, agentArg{Name: name, Required: required}) + arg := agentArg{Name: name, Required: required} + if choices := parseAgentArgChoices(name); len(choices) > 0 { + arg.Name = agentChoiceArgName(cmd) + arg.Choices = choices + } + args = append(args, arg) } return args } +func parseAgentArgChoices(name string) []string { + if !strings.Contains(name, "|") { + return nil + } + parts := strings.Split(name, "|") + choices := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + return nil + } + choices = append(choices, part) + } + return choices +} + +func agentChoiceArgName(cmd *cobra.Command) string { + if cmd.Name() == "completion" { + return "shell" + } + return "value" +} + type serviceTargetFlags struct { Project string Environment string diff --git a/cli/internal/cli/app_test.go b/cli/internal/cli/app_test.go index d88a7dac..f7992cfc 100644 --- a/cli/internal/cli/app_test.go +++ b/cli/internal/cli/app_test.go @@ -62,6 +62,49 @@ func TestAgentHelpOutputsStructuredCommandMetadata(t *testing.T) { } } +func TestAgentCompletionHelpOutputsChoiceArg(t *testing.T) { + stdout, stderr, err := runTestCommand(t, nil, t.TempDir(), "completion", "--help", "--agent") + if err != nil { + t.Fatalf("help error = %v\nstderr=%s", err, stderr) + } + var help agentHelpInfo + if err := json.Unmarshal([]byte(stdout), &help); err != nil { + t.Fatalf("decode help: %v\nstdout=%s", err, stdout) + } + if len(help.Args) != 1 { + t.Fatalf("args = %#v", help.Args) + } + arg := help.Args[0] + if arg.Name != "shell" || !arg.Required { + t.Fatalf("arg = %#v", arg) + } + wantChoices := []string{"bash", "zsh", "fish", "powershell"} + if strings.Join(arg.Choices, ",") != strings.Join(wantChoices, ",") { + t.Fatalf("choices = %#v", arg.Choices) + } +} + +func TestJSONHelpOutputsEnvelope(t *testing.T) { + stdout, stderr, err := runTestCommand(t, nil, t.TempDir(), "status", "--help", "--json") + if err != nil { + t.Fatalf("help error = %v\nstderr=%s", err, stderr) + } + var envelope struct { + OK bool `json:"ok"` + Data agentHelpInfo `json:"data"` + Summary string `json:"summary"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nstdout=%s", err, stdout) + } + if !envelope.OK || envelope.Summary != "Help" { + t.Fatalf("envelope = %#v", envelope) + } + if envelope.Data.Command != "status" || envelope.Data.Path != "tc status" { + t.Fatalf("data = %#v", envelope.Data) + } +} + func TestAgentStatusOutputsRawJSON(t *testing.T) { tmp := t.TempDir() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From b014297c8d98c125c172d3c15dee016adcf3a090 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:25:46 +1000 Subject: [PATCH 07/14] Add service details overview with request stats --- .../[env]/services/[serviceId]/page.tsx | 4 +- .../api/services/[id]/request-stats/route.ts | 43 + .../details/service-details-overview.tsx | 797 ++++++++++++++++++ web/lib/victoria-logs.ts | 223 ++++- web/tests/victoria-logs-request-stats.test.ts | 73 ++ 5 files changed, 1136 insertions(+), 4 deletions(-) create mode 100644 web/app/api/services/[id]/request-stats/route.ts create mode 100644 web/components/service/details/service-details-overview.tsx create mode 100644 web/tests/victoria-logs-request-stats.test.ts diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx index 55850e32..2357c275 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx @@ -18,13 +18,13 @@ import { restartService, stopService, } from "@/actions/projects"; -import { DeploymentCanvas } from "@/components/service/details/deployment-canvas"; import { DeploymentProgress, getBarState, } from "@/components/service/details/deployment-progress"; import { PendingChangesBanner } from "@/components/service/details/pending-changes-banner"; import { RolloutHistory } from "@/components/service/details/rollout-history"; +import { ServiceDetailsOverview } from "@/components/service/details/service-details-overview"; import { useService } from "@/components/service/service-layout-client"; import { AlertDialog, @@ -155,7 +155,7 @@ export default function DeploymentsPage() { barMode={barState.mode} /> - +
}, +) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return new Response("Unauthorized", { status: 401 }); + } + + const { id: serviceId } = await params; + const url = new URL(request.url); + const range = parseRequestStatsRange(url.searchParams.get("range")); + + if (!isLoggingEnabled()) { + return Response.json({ + loggingEnabled: false, + ...createEmptyHttpRequestStats(range), + }); + } + + try { + const stats = await queryHttpRequestStats({ serviceId, range }); + return Response.json({ loggingEnabled: true, ...stats }); + } catch (error) { + console.error("[logs:request-stats] failed to query HTTP stats:", error); + return Response.json({ + loggingEnabled: true, + ...createEmptyHttpRequestStats(range), + }); + } +} diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx new file mode 100644 index 00000000..b1267e86 --- /dev/null +++ b/web/components/service/details/service-details-overview.tsx @@ -0,0 +1,797 @@ +"use client"; + +import { + Activity, + ArrowUpRight, + Box, + Cpu, + GitBranch, + Github, + Globe, + Lock, + type LucideIcon, + Network, + Server, +} from "lucide-react"; +import Link from "next/link"; +import { type ReactNode, useMemo } from "react"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import useSWR from "swr"; +import { useService } from "@/components/service/service-layout-client"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { ServiceWithDetails as Service } from "@/db/types"; +import { fetcher } from "@/lib/fetcher"; +import { cn } from "@/lib/utils"; + +type RequestStatsResponse = { + loggingEnabled: boolean; + range: string; + stepSeconds: number; + currentWindowSeconds: number; + totalRequests: number; + currentRequestsPerSecond: number; + currentErrorsPerSecond: number; + buckets: Array<{ + timestamp: string; + requests: number; + errors: number; + }>; +}; + +type EndpointItem = { + key: string; + label: string; + meta: string; + href?: string; + icon: "http" | "tcp" | "internal"; +}; + +type ServerSummary = { + id: string; + name: string; + configured: number; + running: number; + total: number; +}; + +type OverviewData = { + endpoints: EndpointItem[]; + publicHttpCount: number; + serverSummaries: ServerSummary[]; + runningDeployments: number; + configuredReplicas: number; + totalDeployments: number; + status: ServiceStatus; + source: SourceInfo; +}; + +type SourceInfo = { + icon: LucideIcon; + label: string; + detail: string; + href?: string; + branch?: string | null; +}; + +type ServiceStatus = { + label: string; + tone: "live" | "progress" | "warning" | "muted"; +}; + +type ChartRow = { + timestamp: string; + requestsPerSecond: number; + errorsPerSecond: number; + requests: number; + errors: number; +}; + +type RequestTooltipPayload = { + name?: string; + value?: unknown; + color?: string; + dataKey?: string; + payload?: ChartRow; +}; + +type RequestTooltipProps = { + active?: boolean; + label?: string | number; + payload?: readonly RequestTooltipPayload[]; +}; + +const STATUS_TONE_CLASSES: Record< + ServiceStatus["tone"], + { dot: string; text: string } +> = { + live: { dot: "bg-teal-500", text: "text-teal-700 dark:text-teal-400" }, + progress: { dot: "bg-blue-500", text: "text-blue-700 dark:text-blue-400" }, + warning: { dot: "bg-red-500", text: "text-red-700 dark:text-red-400" }, + muted: { dot: "bg-muted-foreground", text: "text-muted-foreground" }, +}; + +const ACTIVE_BUILD_STATUSES = new Set([ + "pending", + "claimed", + "cloning", + "building", + "pushing", +]); + +export function ServiceDetailsOverview({ service }: { service: Service }) { + const { projectSlug, envName, proxyDomain } = useService(); + const overview = useMemo( + () => buildOverviewData(service, proxyDomain), + [service, proxyDomain], + ); + const requestStatsUrl = + overview.publicHttpCount > 0 + ? `/api/services/${service.id}/request-stats?range=7d` + : null; + const { + data: requestStats, + error: requestStatsError, + isLoading: isRequestStatsLoading, + } = useSWR(requestStatsUrl, fetcher, { + refreshInterval: 60000, + }); + const basePath = `/dashboard/projects/${projectSlug}/${envName}/services/${service.id}`; + const titleEndpoint = overview.endpoints[0]?.label || service.name; + const hiddenEndpointCount = Math.max(0, overview.endpoints.length - 1); + + return ( + +
+
+
+ +
+
+
+

+ {titleEndpoint} +

+ {hiddenEndpointCount > 0 && ( + +{hiddenEndpointCount} + )} +
+

+ {service.name} +

+
+
+
+ + Logs + + + {overview.publicHttpCount > 0 && ( + + Requests + + + )} +
+
+ +
+ 0} + stats={requestStats} + error={requestStatsError} + isLoading={isRequestStatsLoading} + /> + +
+ + + + + +

{formatResources(service)}

+
+ +

+ {overview.runningDeployments}{" "} + running +

+

+ {overview.configuredReplicas} configured ·{" "} + {overview.totalDeployments} total +

+
+ + + + +

{formatRelativeTime(service.createdAt)}

+
+ + + +
+
+
+ ); +} + +function RequestStatsPanel({ + hasPublicHttp, + stats, + error, + isLoading, +}: { + hasPublicHttp: boolean; + stats?: RequestStatsResponse; + error?: unknown; + isLoading: boolean; +}) { + const chartRows = useMemo(() => buildChartRows(stats), [stats]); + const hasChartData = chartRows.some( + (row) => row.requests > 0 || row.errors > 0, + ); + const isUnavailable = Boolean(error) || stats?.loggingEnabled === false; + + return ( +
+
+
+ {isLoading ? ( + + ) : ( +

+ {hasPublicHttp && stats && !isUnavailable + ? formatCompactNumber(stats.totalRequests) + : "-"} +

+ )} +

+ {hasPublicHttp ? "requests this week" : "no public HTTP ingress"} +

+
+

{formatToday()}

+
+ +
+ {!hasPublicHttp ? ( + + ) : isLoading ? ( + + ) : isUnavailable ? ( + + ) : !hasChartData ? ( + + ) : ( + + + + + + ( + + )} + /> + + + + + )} +
+ +
+ + +
+
+ ); +} + +function StatusItem({ status }: { status: ServiceStatus }) { + const classes = STATUS_TONE_CLASSES[status.tone]; + + return ( + +
+ + {status.label} +
+
+ ); +} + +function DetailItem({ + icon: Icon, + label, + children, +}: { + icon: LucideIcon; + label: string; + children: ReactNode; +}) { + return ( +
+
+ + {label} +
+
{children}
+
+ ); +} + +function ServerList({ servers }: { servers: ServerSummary[] }) { + if (servers.length === 0) { + return

No servers configured

; + } + + return ( +
+ {servers.map((server) => ( + + {server.name} + + {server.configured > 0 + ? `${server.running}/${server.configured}` + : `${server.running} running`} + + + ))} +
+ ); +} + +function SourceDetails({ source }: { source: SourceInfo }) { + const content = ( +
+

{source.label}

+
+ {source.branch ? : null} + {source.branch || source.detail} +
+
+ ); + + if (!source.href) return content; + + return ( + + {content} + + ); +} + +function EndpointList({ endpoints }: { endpoints: EndpointItem[] }) { + if (endpoints.length === 0) { + return

No endpoints yet

; + } + + return ( +
+ {endpoints.map((endpoint) => { + const Icon = + endpoint.icon === "http" + ? Globe + : endpoint.icon === "tcp" + ? Network + : Lock; + const content = ( +
+ +
+

{endpoint.label}

+

+ {endpoint.meta} +

+
+
+ ); + + return endpoint.href ? ( + + {content} + + ) : ( +
{content}
+ ); + })} +
+ ); +} + +function LegendMetric({ + color, + label, + value, +}: { + color: string; + label: string; + value: string; +}) { + return ( +
+ + {label} + {value} +
+ ); +} + +function RequestStatsState({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function RequestStatsTooltip({ active, payload, label }: RequestTooltipProps) { + if (!active || !payload?.length) return null; + + const row = payload[0]?.payload; + + return ( +
+

{formatTooltipDate(String(label))}

+
+ {payload.map((item) => ( +
+ + + {item.name} + + + {formatRate(Number(item.value))} + +
+ ))} +
+ {row ? ( +

+ {row.requests} requests · {row.errors} 5xx +

+ ) : null} +
+ ); +} + +function buildOverviewData( + service: Service, + proxyDomain: string | null, +): OverviewData { + const endpoints: EndpointItem[] = []; + const servers = new Map(); + let publicHttpCount = 0; + let runningDeployments = 0; + let totalDeployments = 0; + let configuredReplicas = 0; + + for (const replica of service.configuredReplicas || []) { + configuredReplicas += replica.count; + servers.set(replica.serverId, { + id: replica.serverId, + name: replica.serverName, + configured: replica.count, + running: 0, + total: 0, + }); + } + + for (const deployment of service.deployments || []) { + totalDeployments++; + const serverId = deployment.serverId; + const summary = servers.get(serverId) ?? { + id: serverId, + name: deployment.server?.name || "Unknown", + configured: 0, + running: 0, + total: 0, + }; + summary.total++; + if (deployment.status === "running") { + runningDeployments++; + summary.running++; + } + servers.set(serverId, summary); + } + + for (const port of service.ports || []) { + if (port.isPublic && port.domain && port.protocol === "http") { + publicHttpCount++; + endpoints.push({ + key: port.id, + label: port.domain, + meta: `HTTP :${port.port}`, + href: `https://${port.domain}`, + icon: "http", + }); + continue; + } + + if ( + port.isPublic && + (port.protocol === "tcp" || port.protocol === "udp") && + port.externalPort && + proxyDomain + ) { + endpoints.push({ + key: port.id, + label: `${port.protocol}://${proxyDomain}:${port.externalPort}`, + meta: `Container :${port.port}`, + icon: "tcp", + }); + } + } + + if (runningDeployments > 0) { + endpoints.push({ + key: "internal", + label: `${service.hostname || service.name}.internal`, + meta: "Internal DNS", + icon: "internal", + }); + } + + return { + endpoints, + publicHttpCount, + serverSummaries: Array.from(servers.values()).sort((a, b) => + a.name.localeCompare(b.name), + ), + runningDeployments, + configuredReplicas, + totalDeployments, + status: getServiceStatus(service, runningDeployments), + source: getSourceInfo(service), + }; +} + +function getServiceStatus( + service: Service, + runningDeployments: number, +): ServiceStatus { + const latestRollout = service.rollouts?.[0]; + + if (service.migrationStatus) return { label: "Migrating", tone: "progress" }; + if ( + service.latestBuild && + ACTIVE_BUILD_STATUSES.has(service.latestBuild.status) + ) { + return { label: "Building", tone: "progress" }; + } + if ( + latestRollout?.status === "queued" || + latestRollout?.status === "in_progress" + ) { + return { label: "Deploying", tone: "progress" }; + } + if (runningDeployments > 0) return { label: "Live", tone: "live" }; + + for (const deployment of service.deployments || []) { + if (deployment.status === "failed" || deployment.status === "rolled_back") { + return { label: "Needs attention", tone: "warning" }; + } + } + + return { + label: service.deployments.length > 0 ? "Stopped" : "Not deployed", + tone: "muted", + }; +} + +function getSourceInfo(service: Service): SourceInfo { + if (service.sourceType === "github" && service.githubRepoUrl) { + return { + icon: Github, + label: formatGithubRepo(service.githubRepoUrl), + detail: "GitHub", + href: service.githubRepoUrl, + branch: service.githubBranch || "main", + }; + } + + return { + icon: Box, + label: service.image, + detail: "Docker image", + }; +} + +function buildChartRows(stats?: RequestStatsResponse): ChartRow[] { + if (!stats) return []; + + return stats.buckets.map((bucket) => ({ + timestamp: bucket.timestamp, + requests: bucket.requests, + errors: bucket.errors, + requestsPerSecond: bucket.requests / stats.stepSeconds, + errorsPerSecond: bucket.errors / stats.stepSeconds, + })); +} + +function formatGithubRepo(repoUrl: string): string { + return repoUrl + .replace(/^https:\/\/github\.com\//, "") + .replace(/^git@github\.com:/, "") + .replace(/\.git$/, ""); +} + +function formatResources(service: Service): string { + const cpu = service.resourceCpuLimit; + const memoryMb = service.resourceMemoryLimitMb; + const cpuLabel = + typeof cpu === "number" && Number.isFinite(cpu) + ? `${Number.isInteger(cpu) ? cpu : cpu.toFixed(1)} vCPU` + : "CPU not set"; + const memoryLabel = + typeof memoryMb === "number" && Number.isFinite(memoryMb) + ? `${memoryMb} MiB` + : "Memory not set"; + + return `${cpuLabel} · ${memoryLabel}`; +} + +function formatCompactNumber(value: number): string { + return new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: value >= 1000 ? 1 : 0, + }).format(value); +} + +function formatRate(value: number): string { + if (!Number.isFinite(value)) return "-"; + if (value >= 100) return value.toFixed(0); + if (value >= 10) return value.toFixed(1); + return value.toFixed(2).replace(/\.?0+$/, ""); +} + +function formatRateTick(value: number): string { + if (value >= 100) return value.toFixed(0); + if (value >= 10) return value.toFixed(0); + return value.toFixed(1); +} + +function formatShortDate(value: string): string { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + }).format(new Date(value)); +} + +function formatTooltipDate(value: string): string { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(value)); +} + +function formatToday(): string { + return new Intl.DateTimeFormat(undefined, { + day: "numeric", + month: "short", + }).format(new Date()); +} + +function formatRelativeTime(value: Date | string): string { + const createdAt = new Date(value); + const diffMs = Date.now() - createdAt.getTime(); + + if (!Number.isFinite(diffMs)) return "-"; + if (diffMs < 60 * 1000) return "just now"; + + const minutes = Math.floor(diffMs / (60 * 1000)); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 48) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(createdAt); +} diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts index 1c663c97..b1f5ef3e 100644 --- a/web/lib/victoria-logs.ts +++ b/web/lib/victoria-logs.ts @@ -1,6 +1,30 @@ const VICTORIA_LOGS_URL = process.env.VICTORIA_LOGS_URL; const VICTORIA_LOGS_PRIVATE_URL = process.env.VICTORIA_LOGS_PRIVATE_URL; +export const REQUEST_STATS_RANGE_OPTIONS = { + "1h": { durationMs: 60 * 60 * 1000, stepSeconds: 60, stepLogSql: "1m" }, + "6h": { + durationMs: 6 * 60 * 60 * 1000, + stepSeconds: 5 * 60, + stepLogSql: "5m", + }, + "24h": { + durationMs: 24 * 60 * 60 * 1000, + stepSeconds: 5 * 60, + stepLogSql: "5m", + }, + "7d": { + durationMs: 7 * 24 * 60 * 60 * 1000, + stepSeconds: 30 * 60, + stepLogSql: "30m", + }, +} as const; + +export type RequestStatsRange = keyof typeof REQUEST_STATS_RANGE_OPTIONS; + +export const DEFAULT_REQUEST_STATS_RANGE: RequestStatsRange = "7d"; +const CURRENT_RATE_WINDOW_SECONDS = 5 * 60; + type EndpointConfig = { url: string; username?: string; @@ -52,7 +76,202 @@ export type StoredLog = { }; export function isLoggingEnabled(): boolean { - return !!VICTORIA_LOGS_URL; + return !!(VICTORIA_LOGS_PRIVATE_URL || VICTORIA_LOGS_URL); +} + +export type HttpRequestStatsBucket = { + timestamp: string; + requests: number; + errors: number; +}; + +export type HttpRequestStats = { + range: RequestStatsRange; + stepSeconds: number; + currentWindowSeconds: number; + totalRequests: number; + currentRequestsPerSecond: number; + currentErrorsPerSecond: number; + buckets: HttpRequestStatsBucket[]; +}; + +type RequestStatsRow = Record; + +export function parseRequestStatsRange( + value: string | null | undefined, +): RequestStatsRange { + if (value && value in REQUEST_STATS_RANGE_OPTIONS) { + return value as RequestStatsRange; + } + return DEFAULT_REQUEST_STATS_RANGE; +} + +export function createEmptyHttpRequestStats( + range: RequestStatsRange = DEFAULT_REQUEST_STATS_RANGE, +): HttpRequestStats { + const config = REQUEST_STATS_RANGE_OPTIONS[range]; + return { + range, + stepSeconds: config.stepSeconds, + currentWindowSeconds: CURRENT_RATE_WINDOW_SECONDS, + totalRequests: 0, + currentRequestsPerSecond: 0, + currentErrorsPerSecond: 0, + buckets: [], + }; +} + +export function parseRequestStatsRows(text: string): RequestStatsRow[] { + const lines = text.trim().split("\n").filter(Boolean); + const rows: RequestStatsRow[] = []; + + for (const line of lines) { + try { + const parsed = JSON.parse(line) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + rows.push(parsed as RequestStatsRow); + } + } catch { + // VictoriaLogs should return JSON lines, but a single malformed row should + // not break the service overview card. + } + } + + return rows; +} + +export function sumRequestStatsRows(rows: RequestStatsRow[]): { + requests: number; + errors: number; +} { + return rows.reduce<{ requests: number; errors: number }>( + (acc, row) => { + acc.requests += parseStatNumber(row.requests); + acc.errors += parseStatNumber(row.errors); + return acc; + }, + { requests: 0, errors: 0 }, + ); +} + +export function buildRequestStatsBuckets( + rows: RequestStatsRow[], + options: { start: Date; end: Date; stepSeconds: number }, +): HttpRequestStatsBucket[] { + const stepMs = options.stepSeconds * 1000; + const startMs = floorToStep(options.start.getTime(), stepMs); + const endMs = floorToStep(options.end.getTime(), stepMs); + + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || startMs > endMs) { + return []; + } + + const byBucket = new Map(); + for (const row of rows) { + const timestamp = parseTimestamp(row._time ?? row.time ?? row.timestamp); + if (!timestamp) continue; + + const bucketMs = floorToStep(timestamp.getTime(), stepMs); + const bucket = byBucket.get(bucketMs) ?? { requests: 0, errors: 0 }; + bucket.requests += parseStatNumber(row.requests); + bucket.errors += parseStatNumber(row.errors); + byBucket.set(bucketMs, bucket); + } + + const buckets: HttpRequestStatsBucket[] = []; + for (let timestampMs = startMs; timestampMs <= endMs; timestampMs += stepMs) { + const bucket = byBucket.get(timestampMs) ?? { requests: 0, errors: 0 }; + buckets.push({ + timestamp: new Date(timestampMs).toISOString(), + requests: bucket.requests, + errors: bucket.errors, + }); + } + + return buckets; +} + +export async function queryHttpRequestStats(options: { + serviceId: string; + range: RequestStatsRange; + now?: Date; +}): Promise { + const config = REQUEST_STATS_RANGE_OPTIONS[options.range]; + const now = options.now ?? new Date(); + const start = new Date(now.getTime() - config.durationMs); + const bucketLimit = + Math.ceil(config.durationMs / (config.stepSeconds * 1000)) + 5; + const baseFilter = `service_id:${options.serviceId} log_type:http`; + const bucketQuery = `${baseFilter} _time:${options.range} | stats by (_time:${config.stepLogSql}) count() as requests, count() if (status:>=500) as errors | sort by (_time)`; + const currentQuery = `${baseFilter} _time:5m | stats count() as requests, count() if (status:>=500) as errors`; + + const [bucketText, currentText] = await Promise.all([ + queryLogSql(bucketQuery, bucketLimit), + queryLogSql(currentQuery, 5), + ]); + + const buckets = buildRequestStatsBuckets(parseRequestStatsRows(bucketText), { + start, + end: now, + stepSeconds: config.stepSeconds, + }); + const currentCounts = sumRequestStatsRows(parseRequestStatsRows(currentText)); + + return { + range: options.range, + stepSeconds: config.stepSeconds, + currentWindowSeconds: CURRENT_RATE_WINDOW_SECONDS, + totalRequests: buckets.reduce((sum, bucket) => sum + bucket.requests, 0), + currentRequestsPerSecond: + currentCounts.requests / CURRENT_RATE_WINDOW_SECONDS, + currentErrorsPerSecond: currentCounts.errors / CURRENT_RATE_WINDOW_SECONDS, + buckets, + }; +} + +async function queryLogSql(query: string, limit: number): Promise { + const endpoint = getQueryEndpoint(); + if (!endpoint) { + throw new Error("VICTORIA_LOGS_URL is not configured"); + } + + const url = new URL(`${endpoint.url}/select/logsql/query`); + url.searchParams.set("query", query); + url.searchParams.set("limit", String(limit)); + + const response = await fetch(url.toString(), buildFetchOptions(endpoint)); + + if (!response.ok) { + throw new Error( + `Failed to query logs: ${response.status} ${response.statusText}`, + ); + } + + return response.text(); +} + +function parseStatNumber(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + +function parseTimestamp(value: unknown): Date | null { + if (typeof value !== "string" && typeof value !== "number") { + return null; + } + + const timestamp = new Date(value); + return Number.isFinite(timestamp.getTime()) ? timestamp : null; +} + +function floorToStep(timestampMs: number, stepMs: number): number { + return Math.floor(timestampMs / stepMs) * stepMs; } type QueryLogsByServiceOptions = { @@ -242,7 +461,7 @@ export async function ingestRolloutLog( await fetch(url, { ...options, method: "POST", - body: JSON.stringify(entry) + "\n", + body: `${JSON.stringify(entry)}\n`, headers: { ...((options.headers as Record) || {}), "Content-Type": "application/json", diff --git a/web/tests/victoria-logs-request-stats.test.ts b/web/tests/victoria-logs-request-stats.test.ts new file mode 100644 index 00000000..628dcd87 --- /dev/null +++ b/web/tests/victoria-logs-request-stats.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + buildRequestStatsBuckets, + createEmptyHttpRequestStats, + parseRequestStatsRange, + parseRequestStatsRows, + sumRequestStatsRows, +} from "@/lib/victoria-logs"; + +describe("VictoriaLogs request stats", () => { + it("parses JSON lines and ignores malformed rows", () => { + const rows = parseRequestStatsRows( + [ + '{"_time":"2026-07-02T00:00:00Z","requests":"10","errors":2}', + "not-json", + '{"_time":"2026-07-02T00:01:00Z","requests":5,"errors":"1"}', + ].join("\n"), + ); + + expect(rows).toHaveLength(2); + expect(sumRequestStatsRows(rows)).toEqual({ requests: 15, errors: 3 }); + }); + + it("builds complete buckets and fills gaps with zeroes", () => { + const buckets = buildRequestStatsBuckets( + [ + { _time: "2026-07-02T00:00:10Z", requests: "3", errors: 0 }, + { _time: "2026-07-02T00:02:05Z", requests: 7, errors: "2" }, + ], + { + start: new Date("2026-07-02T00:00:00Z"), + end: new Date("2026-07-02T00:02:30Z"), + stepSeconds: 60, + }, + ); + + expect(buckets).toEqual([ + { + timestamp: "2026-07-02T00:00:00.000Z", + requests: 3, + errors: 0, + }, + { + timestamp: "2026-07-02T00:01:00.000Z", + requests: 0, + errors: 0, + }, + { + timestamp: "2026-07-02T00:02:00.000Z", + requests: 7, + errors: 2, + }, + ]); + }); + + it("falls back to the default range for unsupported values", () => { + expect(parseRequestStatsRange("7d")).toBe("7d"); + expect(parseRequestStatsRange("90d")).toBe("7d"); + expect(parseRequestStatsRange(null)).toBe("7d"); + }); + + it("creates an empty non-breaking stats payload", () => { + expect(createEmptyHttpRequestStats("1h")).toMatchObject({ + range: "1h", + stepSeconds: 60, + currentWindowSeconds: 300, + totalRequests: 0, + currentRequestsPerSecond: 0, + currentErrorsPerSecond: 0, + buckets: [], + }); + }); +}); From ac71d0231d78cd12062f141dfe0a2faa86d3ea04 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:43:27 +1000 Subject: [PATCH 08/14] Refine service details overview layout --- .../details/service-details-overview.tsx | 64 ++++++++----------- 1 file changed, 25 insertions(+), 39 deletions(-) diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index b1267e86..af860055 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -201,28 +201,13 @@ export function ServiceDetailsOverview({ service }: { service: Service }) {
- - - +

{formatResources(service)}

- -

- {overview.runningDeployments}{" "} - running -

-

- {overview.configuredReplicas} configured ·{" "} - {overview.totalDeployments} total -

-
- -

{formatRelativeTime(service.createdAt)}

-
@@ -379,6 +364,30 @@ function StatusItem({ status }: { status: ServiceStatus }) { ); } +function InstancesItem({ overview }: { overview: OverviewData }) { + const serverCount = overview.serverSummaries.length; + const serverLabel = serverCount === 1 ? "server" : "servers"; + + return ( + +
+
+

+ {overview.runningDeployments}{" "} + running + {serverCount > 0 ? ` across ${serverCount} ${serverLabel}` : ""} +

+

+ {overview.configuredReplicas} configured ·{" "} + {overview.totalDeployments} total +

+
+ +
+
+ ); +} + function DetailItem({ icon: Icon, label, @@ -772,26 +781,3 @@ function formatToday(): string { month: "short", }).format(new Date()); } - -function formatRelativeTime(value: Date | string): string { - const createdAt = new Date(value); - const diffMs = Date.now() - createdAt.getTime(); - - if (!Number.isFinite(diffMs)) return "-"; - if (diffMs < 60 * 1000) return "just now"; - - const minutes = Math.floor(diffMs / (60 * 1000)); - if (minutes < 60) return `${minutes}m ago`; - - const hours = Math.floor(minutes / 60); - if (hours < 48) return `${hours}h ago`; - - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }).format(createdAt); -} From e9d999ca9f204a11d10cb471a3ee1ff7357f9a79 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:46:17 +1000 Subject: [PATCH 09/14] Tighten service details header --- .../details/service-details-overview.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index af860055..75a5ad79 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -166,20 +166,10 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { +{hiddenEndpointCount} )}
-

- {service.name} -

-
- - Logs - - - {overview.publicHttpCount > 0 && ( + {overview.publicHttpCount > 0 && ( +
- )} -
+
+ )}
From 964342d39bce52952b1824515024876e073fd5b0 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:47:48 +1000 Subject: [PATCH 10/14] Expand endpoints detail row --- .../service/details/service-details-overview.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 75a5ad79..3b7e56ed 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -198,7 +198,7 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { - +
@@ -382,13 +382,15 @@ function DetailItem({ icon: Icon, label, children, + className, }: { icon: LucideIcon; label: string; children: ReactNode; + className?: string; }) { return ( -
+
{label} @@ -461,11 +463,11 @@ function EndpointList({ endpoints }: { endpoints: EndpointItem[] }) { const content = (
- +
-

{endpoint.label}

+

{endpoint.label}

{endpoint.meta}

From d8470ffed810b161b4449fbc66c32a3db7127e15 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:51:21 +1000 Subject: [PATCH 11/14] Move endpoints into details table --- .../details/service-details-overview.tsx | 150 ++++++++---------- 1 file changed, 67 insertions(+), 83 deletions(-) diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 3b7e56ed..2bf62fdb 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -2,7 +2,6 @@ import { Activity, - ArrowUpRight, Box, Cpu, GitBranch, @@ -13,7 +12,6 @@ import { Network, Server, } from "lucide-react"; -import Link from "next/link"; import { type ReactNode, useMemo } from "react"; import { CartesianGrid, @@ -27,7 +25,6 @@ import { import useSWR from "swr"; import { useService } from "@/components/service/service-layout-client"; import { Badge } from "@/components/ui/badge"; -import { buttonVariants } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import type { ServiceWithDetails as Service } from "@/db/types"; @@ -51,10 +48,11 @@ type RequestStatsResponse = { type EndpointItem = { key: string; + kind: "public" | "private" | "tcp"; + typeLabel: string; label: string; - meta: string; + target: string; href?: string; - icon: "http" | "tcp" | "internal"; }; type ServerSummary = { @@ -129,8 +127,14 @@ const ACTIVE_BUILD_STATUSES = new Set([ "pushing", ]); +const ENDPOINT_KIND_ICONS: Record = { + public: Globe, + private: Lock, + tcp: Network, +}; + export function ServiceDetailsOverview({ service }: { service: Service }) { - const { projectSlug, envName, proxyDomain } = useService(); + const { proxyDomain } = useService(); const overview = useMemo( () => buildOverviewData(service, proxyDomain), [service, proxyDomain], @@ -146,41 +150,9 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { } = useSWR(requestStatsUrl, fetcher, { refreshInterval: 60000, }); - const basePath = `/dashboard/projects/${projectSlug}/${envName}/services/${service.id}`; - const titleEndpoint = overview.endpoints[0]?.label || service.name; - const hiddenEndpointCount = Math.max(0, overview.endpoints.length - 1); return ( -
-
-
- -
-
-
-

- {titleEndpoint} -

- {hiddenEndpointCount > 0 && ( - +{hiddenEndpointCount} - )} -
-
-
- {overview.publicHttpCount > 0 && ( -
- - Requests - - -
- )} -
-
0} @@ -199,7 +171,7 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { - +
@@ -446,49 +418,58 @@ function SourceDetails({ source }: { source: SourceInfo }) { ); } -function EndpointList({ endpoints }: { endpoints: EndpointItem[] }) { +function EndpointTable({ endpoints }: { endpoints: EndpointItem[] }) { if (endpoints.length === 0) { return

No endpoints yet

; } return ( -
- {endpoints.map((endpoint) => { - const Icon = - endpoint.icon === "http" - ? Globe - : endpoint.icon === "tcp" - ? Network - : Lock; - const content = ( -
- -
-

{endpoint.label}

-

- {endpoint.meta} -

-
-
- ); - - return endpoint.href ? ( - - {content} - - ) : ( -
{content}
- ); - })} +
+
+ + + + + + + + + + {endpoints.map((endpoint) => { + const Icon = ENDPOINT_KIND_ICONS[endpoint.kind]; + return ( + + + + + + ); + })} + +
TypeEndpointTarget
+
+ + {endpoint.typeLabel} +
+
+ {endpoint.href ? ( + + {endpoint.label} + + ) : ( + + {endpoint.label} + + )} + + {endpoint.target} +
+
); } @@ -600,10 +581,11 @@ function buildOverviewData( publicHttpCount++; endpoints.push({ key: port.id, + kind: "public", + typeLabel: "Public", label: port.domain, - meta: `HTTP :${port.port}`, + target: `HTTP :${port.port}`, href: `https://${port.domain}`, - icon: "http", }); continue; } @@ -616,9 +598,10 @@ function buildOverviewData( ) { endpoints.push({ key: port.id, + kind: "tcp", + typeLabel: port.protocol.toUpperCase(), label: `${port.protocol}://${proxyDomain}:${port.externalPort}`, - meta: `Container :${port.port}`, - icon: "tcp", + target: `Container :${port.port}`, }); } } @@ -626,9 +609,10 @@ function buildOverviewData( if (runningDeployments > 0) { endpoints.push({ key: "internal", + kind: "private", + typeLabel: "Private", label: `${service.hostname || service.name}.internal`, - meta: "Internal DNS", - icon: "internal", + target: "Internal DNS", }); } From b0fc8a3861a5a421ee43d5c8d5cbb90619256586 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:52:59 +1000 Subject: [PATCH 12/14] Simplify instances detail --- .../service/details/service-details-overview.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 2bf62fdb..85847010 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -68,8 +68,6 @@ type OverviewData = { publicHttpCount: number; serverSummaries: ServerSummary[]; runningDeployments: number; - configuredReplicas: number; - totalDeployments: number; status: ServiceStatus; source: SourceInfo; }; @@ -339,10 +337,6 @@ function InstancesItem({ overview }: { overview: OverviewData }) { running {serverCount > 0 ? ` across ${serverCount} ${serverLabel}` : ""}

-

- {overview.configuredReplicas} configured ·{" "} - {overview.totalDeployments} total -

@@ -544,11 +538,8 @@ function buildOverviewData( const servers = new Map(); let publicHttpCount = 0; let runningDeployments = 0; - let totalDeployments = 0; - let configuredReplicas = 0; for (const replica of service.configuredReplicas || []) { - configuredReplicas += replica.count; servers.set(replica.serverId, { id: replica.serverId, name: replica.serverName, @@ -559,7 +550,6 @@ function buildOverviewData( } for (const deployment of service.deployments || []) { - totalDeployments++; const serverId = deployment.serverId; const summary = servers.get(serverId) ?? { id: serverId, @@ -623,8 +613,6 @@ function buildOverviewData( a.name.localeCompare(b.name), ), runningDeployments, - configuredReplicas, - totalDeployments, status: getServiceStatus(service, runningDeployments), source: getSourceInfo(service), }; From 897d397569e48eeba3e4fdf1aad8a503510dc79d Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:06:02 +1000 Subject: [PATCH 13/14] Graph request stats by status code --- .../details/service-details-overview.tsx | 162 +++++++++++------- web/lib/victoria-logs.ts | 101 ++++++++--- web/tests/victoria-logs-request-stats.test.ts | 29 ++-- 3 files changed, 199 insertions(+), 93 deletions(-) diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 85847010..d8eaad7f 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -38,11 +38,16 @@ type RequestStatsResponse = { currentWindowSeconds: number; totalRequests: number; currentRequestsPerSecond: number; - currentErrorsPerSecond: number; + statusCodes: string[]; + currentStatuses: Array<{ + status: string; + requests: number; + requestsPerSecond: number; + }>; buckets: Array<{ timestamp: string; - requests: number; - errors: number; + totalRequests: number; + statuses: Record; }>; }; @@ -87,10 +92,14 @@ type ServiceStatus = { type ChartRow = { timestamp: string; - requestsPerSecond: number; - errorsPerSecond: number; - requests: number; - errors: number; + totalRequests: number; +} & Record; + +type StatusSeries = { + status: string; + dataKey: string; + color: string; + currentRequestsPerSecond: number; }; type RequestTooltipPayload = { @@ -131,6 +140,14 @@ const ENDPOINT_KIND_ICONS: Record = { tcp: Network, }; +const STATUS_CODE_COLOR_PALETTES: Record = { + "2": ["#10b981", "#22c55e", "#14b8a6", "#84cc16"], + "3": ["#6366f1", "#8b5cf6", "#06b6d4", "#3b82f6"], + "4": ["#f59e0b", "#f97316", "#eab308", "#fb7185"], + "5": ["#ef4444", "#f43f5e", "#dc2626", "#b91c1c"], + default: ["#64748b", "#0ea5e9", "#a855f7", "#71717a"], +}; + export function ServiceDetailsOverview({ service }: { service: Service }) { const { proxyDomain } = useService(); const overview = useMemo( @@ -189,9 +206,8 @@ function RequestStatsPanel({ isLoading: boolean; }) { const chartRows = useMemo(() => buildChartRows(stats), [stats]); - const hasChartData = chartRows.some( - (row) => row.requests > 0 || row.errors > 0, - ); + const statusSeries = useMemo(() => buildStatusSeries(stats), [stats]); + const hasChartData = chartRows.some((row) => row.totalRequests > 0); const isUnavailable = Boolean(error) || stats?.loggingEnabled === false; return ( @@ -262,51 +278,40 @@ function RequestStatsPanel({ /> )} /> - - + {statusSeries.map((series) => ( + + ))} )}
-
- - -
+ {statusSeries.length > 0 && ( +
+ {statusSeries.map((series) => ( + + ))} +
+ )}
); } @@ -479,7 +484,10 @@ function LegendMetric({ }) { return (
- + {label} {value}
@@ -498,12 +506,14 @@ function RequestStatsTooltip({ active, payload, label }: RequestTooltipProps) { if (!active || !payload?.length) return null; const row = payload[0]?.payload; + const visiblePayload = payload.filter((item) => Number(item.value) > 0); + const items = visiblePayload.length > 0 ? visiblePayload : payload; return (

{formatTooltipDate(String(label))}

- {payload.map((item) => ( + {items.map((item) => (
- {formatRate(Number(item.value))} + {formatRate(Number(item.value))}/s
))}
{row ? (

- {row.requests} requests · {row.errors} 5xx + {row.totalRequests} total requests

) : null}
@@ -672,15 +682,47 @@ function getSourceInfo(service: Service): SourceInfo { function buildChartRows(stats?: RequestStatsResponse): ChartRow[] { if (!stats) return []; - return stats.buckets.map((bucket) => ({ - timestamp: bucket.timestamp, - requests: bucket.requests, - errors: bucket.errors, - requestsPerSecond: bucket.requests / stats.stepSeconds, - errorsPerSecond: bucket.errors / stats.stepSeconds, + return stats.buckets.map((bucket) => { + const row: ChartRow = { + timestamp: bucket.timestamp, + totalRequests: bucket.totalRequests, + }; + for (const status of stats.statusCodes) { + row[getStatusDataKey(status)] = + (bucket.statuses[status] ?? 0) / stats.stepSeconds; + } + return row; + }); +} + +function buildStatusSeries(stats?: RequestStatsResponse): StatusSeries[] { + if (!stats) return []; + + const currentByStatus = new Map( + stats.currentStatuses.map((status) => [status.status, status]), + ); + + return stats.statusCodes.map((status, index) => ({ + status, + dataKey: getStatusDataKey(status), + color: getStatusColor(status, index), + currentRequestsPerSecond: + currentByStatus.get(status)?.requestsPerSecond ?? 0, })); } +function getStatusDataKey(status: string): string { + const normalized = status.replace(/[^a-zA-Z0-9]/g, "_"); + return `status_${normalized || "unknown"}`; +} + +function getStatusColor(status: string, index: number): string { + const palette = + STATUS_CODE_COLOR_PALETTES[status.charAt(0)] ?? + STATUS_CODE_COLOR_PALETTES.default; + return palette[index % palette.length]; +} + function formatGithubRepo(repoUrl: string): string { return repoUrl .replace(/^https:\/\/github\.com\//, "") diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts index b1f5ef3e..b5b821a2 100644 --- a/web/lib/victoria-logs.ts +++ b/web/lib/victoria-logs.ts @@ -81,8 +81,14 @@ export function isLoggingEnabled(): boolean { export type HttpRequestStatsBucket = { timestamp: string; + totalRequests: number; + statuses: Record; +}; + +export type HttpStatusCodeStats = { + status: string; requests: number; - errors: number; + requestsPerSecond: number; }; export type HttpRequestStats = { @@ -91,7 +97,8 @@ export type HttpRequestStats = { currentWindowSeconds: number; totalRequests: number; currentRequestsPerSecond: number; - currentErrorsPerSecond: number; + statusCodes: string[]; + currentStatuses: HttpStatusCodeStats[]; buckets: HttpRequestStatsBucket[]; }; @@ -116,7 +123,8 @@ export function createEmptyHttpRequestStats( currentWindowSeconds: CURRENT_RATE_WINDOW_SECONDS, totalRequests: 0, currentRequestsPerSecond: 0, - currentErrorsPerSecond: 0, + statusCodes: [], + currentStatuses: [], buckets: [], }; } @@ -142,15 +150,17 @@ export function parseRequestStatsRows(text: string): RequestStatsRow[] { export function sumRequestStatsRows(rows: RequestStatsRow[]): { requests: number; - errors: number; + statuses: Record; } { - return rows.reduce<{ requests: number; errors: number }>( + return rows.reduce<{ requests: number; statuses: Record }>( (acc, row) => { - acc.requests += parseStatNumber(row.requests); - acc.errors += parseStatNumber(row.errors); + const requests = parseStatNumber(row.requests); + const status = normalizeStatus(row.status); + acc.requests += requests; + acc.statuses[status] = (acc.statuses[status] ?? 0) + requests; return acc; }, - { requests: 0, errors: 0 }, + { requests: 0, statuses: {} }, ); } @@ -166,25 +176,36 @@ export function buildRequestStatsBuckets( return []; } - const byBucket = new Map(); + const byBucket = new Map< + number, + { totalRequests: number; statuses: Record } + >(); for (const row of rows) { const timestamp = parseTimestamp(row._time ?? row.time ?? row.timestamp); if (!timestamp) continue; const bucketMs = floorToStep(timestamp.getTime(), stepMs); - const bucket = byBucket.get(bucketMs) ?? { requests: 0, errors: 0 }; - bucket.requests += parseStatNumber(row.requests); - bucket.errors += parseStatNumber(row.errors); + const bucket = byBucket.get(bucketMs) ?? { + totalRequests: 0, + statuses: {}, + }; + const requests = parseStatNumber(row.requests); + const status = normalizeStatus(row.status); + bucket.totalRequests += requests; + bucket.statuses[status] = (bucket.statuses[status] ?? 0) + requests; byBucket.set(bucketMs, bucket); } const buckets: HttpRequestStatsBucket[] = []; for (let timestampMs = startMs; timestampMs <= endMs; timestampMs += stepMs) { - const bucket = byBucket.get(timestampMs) ?? { requests: 0, errors: 0 }; + const bucket = byBucket.get(timestampMs) ?? { + totalRequests: 0, + statuses: {}, + }; buckets.push({ timestamp: new Date(timestampMs).toISOString(), - requests: bucket.requests, - errors: bucket.errors, + totalRequests: bucket.totalRequests, + statuses: bucket.statuses, }); } @@ -199,15 +220,17 @@ export async function queryHttpRequestStats(options: { const config = REQUEST_STATS_RANGE_OPTIONS[options.range]; const now = options.now ?? new Date(); const start = new Date(now.getTime() - config.durationMs); + const statusCodeLimit = 64; const bucketLimit = - Math.ceil(config.durationMs / (config.stepSeconds * 1000)) + 5; + (Math.ceil(config.durationMs / (config.stepSeconds * 1000)) + 5) * + statusCodeLimit; const baseFilter = `service_id:${options.serviceId} log_type:http`; - const bucketQuery = `${baseFilter} _time:${options.range} | stats by (_time:${config.stepLogSql}) count() as requests, count() if (status:>=500) as errors | sort by (_time)`; - const currentQuery = `${baseFilter} _time:5m | stats count() as requests, count() if (status:>=500) as errors`; + const bucketQuery = `${baseFilter} _time:${options.range} | stats by (_time:${config.stepLogSql}, status) count() as requests | sort by (_time)`; + const currentQuery = `${baseFilter} _time:5m | stats by (status) count() as requests`; const [bucketText, currentText] = await Promise.all([ queryLogSql(bucketQuery, bucketLimit), - queryLogSql(currentQuery, 5), + queryLogSql(currentQuery, statusCodeLimit), ]); const buckets = buildRequestStatsBuckets(parseRequestStatsRows(bucketText), { @@ -216,15 +239,30 @@ export async function queryHttpRequestStats(options: { stepSeconds: config.stepSeconds, }); const currentCounts = sumRequestStatsRows(parseRequestStatsRows(currentText)); + const statusCodes = sortStatusCodes([ + ...buckets.flatMap((bucket) => Object.keys(bucket.statuses)), + ...Object.keys(currentCounts.statuses), + ]); return { range: options.range, stepSeconds: config.stepSeconds, currentWindowSeconds: CURRENT_RATE_WINDOW_SECONDS, - totalRequests: buckets.reduce((sum, bucket) => sum + bucket.requests, 0), + totalRequests: buckets.reduce( + (sum, bucket) => sum + bucket.totalRequests, + 0, + ), currentRequestsPerSecond: currentCounts.requests / CURRENT_RATE_WINDOW_SECONDS, - currentErrorsPerSecond: currentCounts.errors / CURRENT_RATE_WINDOW_SECONDS, + statusCodes, + currentStatuses: statusCodes.map((status) => { + const requests = currentCounts.statuses[status] ?? 0; + return { + status, + requests, + requestsPerSecond: requests / CURRENT_RATE_WINDOW_SECONDS, + }; + }), buckets, }; } @@ -261,6 +299,27 @@ function parseStatNumber(value: unknown): number { return 0; } +function normalizeStatus(value: unknown): string { + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + if (typeof value === "string" && value.trim() !== "") { + return value.trim(); + } + return "unknown"; +} + +function sortStatusCodes(statuses: string[]): string[] { + return Array.from(new Set(statuses)).sort((a, b) => { + const statusA = Number(a); + const statusB = Number(b); + if (Number.isFinite(statusA) && Number.isFinite(statusB)) { + return statusA - statusB; + } + return a.localeCompare(b); + }); +} + function parseTimestamp(value: unknown): Date | null { if (typeof value !== "string" && typeof value !== "number") { return null; diff --git a/web/tests/victoria-logs-request-stats.test.ts b/web/tests/victoria-logs-request-stats.test.ts index 628dcd87..07ff247a 100644 --- a/web/tests/victoria-logs-request-stats.test.ts +++ b/web/tests/victoria-logs-request-stats.test.ts @@ -11,21 +11,25 @@ describe("VictoriaLogs request stats", () => { it("parses JSON lines and ignores malformed rows", () => { const rows = parseRequestStatsRows( [ - '{"_time":"2026-07-02T00:00:00Z","requests":"10","errors":2}', + '{"_time":"2026-07-02T00:00:00Z","status":200,"requests":"10"}', "not-json", - '{"_time":"2026-07-02T00:01:00Z","requests":5,"errors":"1"}', + '{"_time":"2026-07-02T00:01:00Z","status":"500","requests":5}', ].join("\n"), ); expect(rows).toHaveLength(2); - expect(sumRequestStatsRows(rows)).toEqual({ requests: 15, errors: 3 }); + expect(sumRequestStatsRows(rows)).toEqual({ + requests: 15, + statuses: { "200": 10, "500": 5 }, + }); }); it("builds complete buckets and fills gaps with zeroes", () => { const buckets = buildRequestStatsBuckets( [ - { _time: "2026-07-02T00:00:10Z", requests: "3", errors: 0 }, - { _time: "2026-07-02T00:02:05Z", requests: 7, errors: "2" }, + { _time: "2026-07-02T00:00:10Z", status: "200", requests: "3" }, + { _time: "2026-07-02T00:02:05Z", status: "404", requests: 7 }, + { _time: "2026-07-02T00:02:15Z", status: "500", requests: "2" }, ], { start: new Date("2026-07-02T00:00:00Z"), @@ -37,18 +41,18 @@ describe("VictoriaLogs request stats", () => { expect(buckets).toEqual([ { timestamp: "2026-07-02T00:00:00.000Z", - requests: 3, - errors: 0, + totalRequests: 3, + statuses: { "200": 3 }, }, { timestamp: "2026-07-02T00:01:00.000Z", - requests: 0, - errors: 0, + totalRequests: 0, + statuses: {}, }, { timestamp: "2026-07-02T00:02:00.000Z", - requests: 7, - errors: 2, + totalRequests: 9, + statuses: { "404": 7, "500": 2 }, }, ]); }); @@ -66,7 +70,8 @@ describe("VictoriaLogs request stats", () => { currentWindowSeconds: 300, totalRequests: 0, currentRequestsPerSecond: 0, - currentErrorsPerSecond: 0, + statusCodes: [], + currentStatuses: [], buckets: [], }); }); From b646c4302cd40b0e56215f568fae3f2cf073b1eb Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Thu, 2 Jul 2026 21:13:38 +1000 Subject: [PATCH 14/14] Update service details UI --- .../service/details/deployment-canvas.tsx | 390 --------------- .../details/service-details-overview.tsx | 445 ++++++++++++------ 2 files changed, 303 insertions(+), 532 deletions(-) delete mode 100644 web/components/service/details/deployment-canvas.tsx diff --git a/web/components/service/details/deployment-canvas.tsx b/web/components/service/details/deployment-canvas.tsx deleted file mode 100644 index 600ef6f8..00000000 --- a/web/components/service/details/deployment-canvas.tsx +++ /dev/null @@ -1,390 +0,0 @@ -"use client"; - -import { - ArrowRight, - Box, - Globe, - HardDrive, - HeartPulse, - Lock, - Network, -} from "lucide-react"; -import { - CanvasWrapper, - getHealthColor, - getStatusColor, -} from "@/components/ui/canvas-wrapper"; -import { Spinner } from "@/components/ui/spinner"; -import { useService } from "@/components/service/service-layout-client"; -import type { - Deployment as BaseDeployment, - DeploymentPort, - Server as ServerType, - ServiceWithDetails as Service, - ServiceVolume, -} from "@/db/types"; - -type Deployment = BaseDeployment & { - server: Pick | null; - ports: Array< - Pick & { containerPort: number } - >; -}; - -const statusLabels: Record = { - pending: "Queued", - pulling: "Creating", - starting: "Creating", - healthy: "Healthy", - running: "Running", - stopping: "Stopping", - stopped: "Stopped", - failed: "Failed", - rolled_back: "Rolled back", - unknown: "Unknown", -}; - -function getStatusLabel(status: string): string { - return statusLabels[status] || status; -} - -function DeploymentCard({ deployment }: { deployment: Deployment }) { - const colors = getStatusColor(deployment.status); - const healthColor = - deployment.healthStatus && deployment.healthStatus !== "none" - ? getHealthColor(deployment.healthStatus) - : null; - const isTransitioning = - deployment.status === "pending" || - deployment.status === "pulling" || - deployment.status === "starting" || - deployment.status === "stopping"; - - return ( -
-
-
- - {deployment.containerId && ( - - {deployment.containerId.slice(0, 8)} - - )} -
-
- - {deployment.status === "running" && ( - - )} - - - - {getStatusLabel(deployment.status)} - - {isTransitioning && } -
-
- - {healthColor && deployment.status === "running" && ( -
- - - {deployment.healthStatus} - -
- )} -
- ); -} - -function EndpointsCard({ - publicPorts, - tcpUdpPorts, - proxyDomain, - internalHostname, - hasRunningDeployments, -}: { - publicPorts: Array<{ id: string; domain: string | null }>; - tcpUdpPorts: Array<{ - id: string; - protocol: string | null; - externalPort: number | null; - }>; - proxyDomain: string | null; - internalHostname: string; - hasRunningDeployments: boolean; -}) { - return ( -
-
-

Endpoints

-
- - {publicPorts.length > 0 && ( -
-
-
- - Public domain -
-
- - - Active - -
-
-
- {publicPorts.map((port) => ( - - {port.domain} - - ))} -
-
- )} - - {tcpUdpPorts.length > 0 && proxyDomain && ( -
-
-
- - TCP/UDP proxy -
-
- - - Active - -
-
-
- {tcpUdpPorts.map((port) => ( -

- {port.protocol}://{proxyDomain}:{port.externalPort} -

- ))} -
-
- )} - - {hasRunningDeployments && ( -
-
-
- - Internal -
-
- - - Active - -
-
-

- {internalHostname} -

-
- )} -
- ); -} - -function VolumeCard({ volumes }: { volumes: ServiceVolume[] }) { - return ( -
- {volumes.map((volume) => ( -
-
- - - {volume.name} - -
-

- {volume.containerPath} -

-
- ))} -
- ); -} - -function ServerBox({ - serverName, - deployments, -}: { - serverName: string; - deployments: Deployment[]; -}) { - return ( -
-
-

- {serverName} -

-
- -
- {deployments.map((deployment) => ( - - ))} -
-
- ); -} - -interface DeploymentCanvasProps { - service: Service; -} - -export function DeploymentCanvas({ service }: DeploymentCanvasProps) { - const { proxyDomain } = useService(); - const publicPorts = service.ports.filter((p) => p.isPublic && p.domain); - const tcpUdpPorts = service.ports.filter( - (p) => - (p.protocol === "tcp" || p.protocol === "udp") && - p.isPublic && - p.externalPort, - ); - const hasPublicIngress = publicPorts.length > 0; - const hasTcpUdpPorts = tcpUdpPorts.length > 0 && proxyDomain; - const hasRunningDeployments = service.deployments.some( - (d) => d.status === "running", - ); - - const deploymentsByServer = service.deployments.reduce( - (acc, deployment) => { - const serverId = deployment.serverId; - if (!acc[serverId]) { - acc[serverId] = { - serverName: deployment.server?.name || "Unknown", - deployments: [], - }; - } - acc[serverId].deployments.push(deployment); - return acc; - }, - {} as Record, - ); - - const serverGroups = Object.values(deploymentsByServer); - - if (service.deployments.length === 0) { - return ( - -
- -
-

No deployments yet.

-
- } - /> - ); - } - - const hasEndpoints = - hasPublicIngress || hasTcpUdpPorts || hasRunningDeployments; - const hasVolumes = service.volumes && service.volumes.length > 0; - - return ( - <> - -
- {hasEndpoints && ( - - )} - {serverGroups.map((group) => ( -
- - {hasVolumes && } -
- ))} -
-
- - -
- {hasEndpoints && ( - - )} - - {hasEndpoints && ( - - )} - -
- {serverGroups.map((group) => ( -
- - {hasVolumes && } -
- ))} -
-
-
- - ); -} diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index d8eaad7f..4871c083 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -4,12 +4,10 @@ import { Activity, Box, Cpu, - GitBranch, Github, Globe, - Lock, + HardDrive, type LucideIcon, - Network, Server, } from "lucide-react"; import { type ReactNode, useMemo } from "react"; @@ -134,12 +132,6 @@ const ACTIVE_BUILD_STATUSES = new Set([ "pushing", ]); -const ENDPOINT_KIND_ICONS: Record = { - public: Globe, - private: Lock, - tcp: Network, -}; - const STATUS_CODE_COLOR_PALETTES: Record = { "2": ["#10b981", "#22c55e", "#14b8a6", "#84cc16"], "3": ["#6366f1", "#8b5cf6", "#06b6d4", "#3b82f6"], @@ -168,7 +160,7 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { return ( -
+
0} stats={requestStats} @@ -176,19 +168,7 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { isLoading={isRequestStatsLoading} /> -
- - - -

{formatResources(service)}

-
- - - - - - -
+
); @@ -211,7 +191,7 @@ function RequestStatsPanel({ const isUnavailable = Boolean(error) || stats?.loggingEnabled === false; return ( -
+
{isLoading ? ( @@ -230,7 +210,7 @@ function RequestStatsPanel({

{formatToday()}

-
+
{!hasPublicHttp ? ( ) : isLoading ? ( @@ -316,57 +296,197 @@ function RequestStatsPanel({ ); } -function StatusItem({ status }: { status: ServiceStatus }) { - const classes = STATUS_TONE_CLASSES[status.tone]; +function ServiceConfigPanel({ + service, + overview, +}: { + service: Service; + overview: OverviewData; +}) { + const primaryEndpoint = getPrimaryEndpoint(overview.endpoints); return ( - -
- - {status.label} +
+
+ + + + +

+ {formatInstanceSummary(overview)} +

+ +
+ + } + > + + {overview.source.branch || overview.source.detail} + + {service.githubRootDir ? ( + {service.githubRootDir} + ) : null} + + {service.startCommand ? "Custom Command" : "Image Command"} + + + {service.healthCheckCmd ? "Health Check" : "No Health Check"} + + + + + {formatReplicaCompact(overview)} + {formatPlacementLabel(service)} + {overview.serverSummaries.length > 0 ? ( + + {formatCount(overview.serverSummaries.length, "server")} + + ) : null} + + + } + > + {formatEndpointCount(overview.endpoints)} + {formatPortSummary(service.ports || [])} + {primaryEndpoint.kind !== "private" ? ( + {`${service.hostname || service.name}.internal`} + ) : null} + + + + {formatBackupLabel(service)} + {formatDeployScheduleLabel(service)} +
- +
); } -function InstancesItem({ overview }: { overview: OverviewData }) { - const serverCount = overview.serverSummaries.length; - const serverLabel = serverCount === 1 ? "server" : "servers"; - +function SummaryItem({ + icon: Icon, + label, + children, + className, +}: { + icon: LucideIcon; + label: string; + children: ReactNode; + className?: string; +}) { return ( - -
-
-

- {overview.runningDeployments}{" "} - running - {serverCount > 0 ? ` across ${serverCount} ${serverLabel}` : ""} -

-
- +
+
+ + {label} +
+
+ {children}
- +
); } -function DetailItem({ +function ConfigDigestItem({ icon: Icon, label, + primary, children, className, }: { icon: LucideIcon; label: string; + primary: ReactNode; children: ReactNode; className?: string; }) { return ( -
+
{label}
-
{children}
+
+ {primary} +
+
{children}
+
+ ); +} + +function ConfigChip({ + children, + tone = "muted", +}: { + children: ReactNode; + tone?: "active" | "muted"; +}) { + return ( + + {children} + + ); +} + +function SourcePrimary({ source }: { source: SourceInfo }) { + if (source.href) { + return ( + + {source.label} + + ); + } + + return {source.label}; +} + +function EndpointPrimary({ endpoint }: { endpoint: EndpointItem }) { + if (endpoint.href) { + return ( + + {endpoint.label} + + ); + } + + return {endpoint.label}; +} + +function StatusValue({ status }: { status: ServiceStatus }) { + const classes = STATUS_TONE_CLASSES[status.tone]; + + return ( +
+ + + {status.label} +
); } @@ -384,7 +504,7 @@ function ServerList({ servers }: { servers: ServerSummary[] }) { {server.configured > 0 ? `${server.running}/${server.configured}` - : `${server.running} running`} + : `${server.running} Running`} ))} @@ -392,87 +512,6 @@ function ServerList({ servers }: { servers: ServerSummary[] }) { ); } -function SourceDetails({ source }: { source: SourceInfo }) { - const content = ( -
-

{source.label}

-
- {source.branch ? : null} - {source.branch || source.detail} -
-
- ); - - if (!source.href) return content; - - return ( - - {content} - - ); -} - -function EndpointTable({ endpoints }: { endpoints: EndpointItem[] }) { - if (endpoints.length === 0) { - return

No endpoints yet

; - } - - return ( -
-
- - - - - - - - - - {endpoints.map((endpoint) => { - const Icon = ENDPOINT_KIND_ICONS[endpoint.kind]; - return ( - - - - - - ); - })} - -
TypeEndpointTarget
-
- - {endpoint.typeLabel} -
-
- {endpoint.href ? ( - - {endpoint.label} - - ) : ( - - {endpoint.label} - - )} - - {endpoint.target} -
-
-
- ); -} - function LegendMetric({ color, label, @@ -548,6 +587,7 @@ function buildOverviewData( const servers = new Map(); let publicHttpCount = 0; let runningDeployments = 0; + const privateHostname = `${service.hostname || service.name}.internal`; for (const replica of service.configuredReplicas || []) { servers.set(replica.serverId, { @@ -606,15 +646,13 @@ function buildOverviewData( } } - if (runningDeployments > 0) { - endpoints.push({ - key: "internal", - kind: "private", - typeLabel: "Private", - label: `${service.hostname || service.name}.internal`, - target: "Internal DNS", - }); - } + endpoints.push({ + key: "internal", + kind: "private", + typeLabel: "Private", + label: privateHostname, + target: "Internal DNS", + }); return { endpoints, @@ -675,7 +713,7 @@ function getSourceInfo(service: Service): SourceInfo { return { icon: Box, label: service.image, - detail: "Docker image", + detail: "Docker Image", }; } @@ -745,6 +783,129 @@ function formatResources(service: Service): string { return `${cpuLabel} · ${memoryLabel}`; } +function getConfiguredReplicaCount(overview: OverviewData): number { + return overview.serverSummaries.reduce( + (total, server) => total + server.configured, + 0, + ); +} + +function formatInstanceSummary(overview: OverviewData): string { + const configured = getConfiguredReplicaCount(overview); + const serverCount = overview.serverSummaries.length; + + if (configured === 0) { + return overview.runningDeployments > 0 + ? `${overview.runningDeployments} Running` + : "No Replicas"; + } + + return `${overview.runningDeployments}/${configured} Running Across ${formatCount(serverCount, "server")}`; +} + +function getLockedServerLabel(service: Service): string | null { + if (!service.lockedServerId) return null; + + return ( + service.lockedServer?.name ?? + service.configuredReplicas.find( + (replica) => replica.serverId === service.lockedServerId, + )?.serverName ?? + service.lockedServerId + ); +} + +function getPrimaryEndpoint(endpoints: EndpointItem[]): EndpointItem { + return ( + endpoints.find((endpoint) => endpoint.kind === "public") ?? + endpoints.find((endpoint) => endpoint.kind === "tcp") ?? + endpoints[0] ?? { + key: "none", + kind: "private", + typeLabel: "Private", + label: "No endpoint", + target: "Internal DNS", + } + ); +} + +function formatReplicaCompact(overview: OverviewData): string { + const configured = getConfiguredReplicaCount(overview); + + if (configured === 0) return "No Replicas"; + + return `${overview.runningDeployments}/${configured} Running`; +} + +function formatPlacementLabel(service: Service): string { + const lockedServer = getLockedServerLabel(service); + + if (service.stateful) { + return lockedServer ? `Stateful on ${lockedServer}` : "Stateful"; + } + + return lockedServer ? `Pinned to ${lockedServer}` : "Stateless"; +} + +function formatEndpointCount(endpoints: EndpointItem[]): string { + const publicCount = endpoints.filter( + (endpoint) => endpoint.kind !== "private", + ).length; + + if (publicCount === 0) return "Private Only"; + + return `${formatCount(publicCount, "public endpoint")} + Private`; +} + +function formatPortSummary(ports: Service["ports"]): string { + if (ports.length === 0) return "No Ports"; + if (ports.length === 1) return formatPortLabel(ports[0]); + + return formatCount(ports.length, "port"); +} + +function formatPortLabel(port: Service["ports"][number]): string { + const protocol = (port.protocol || "http").toUpperCase(); + const external = port.externalPort ? ` -> :${port.externalPort}` : ""; + + return `${protocol} :${port.port}${external}`; +} + +function formatDataSummary(service: Service): string { + const volumeCount = service.volumes?.length ?? 0; + const secretCount = service.secrets?.length ?? 0; + const parts = []; + + if (volumeCount > 0) parts.push(formatCount(volumeCount, "volume")); + if (secretCount > 0) parts.push(formatCount(secretCount, "secret")); + + return parts.length > 0 ? parts.join(" · ") : "No Volumes or Secrets"; +} + +function formatBackupLabel(service: Service): string { + if ((service.volumes?.length ?? 0) === 0) return "No Backups"; + if (!service.backupEnabled) return "Manual Backups"; + + return service.backupSchedule + ? `Backup ${service.backupSchedule}` + : "Backups On"; +} + +function formatDeployScheduleLabel(service: Service): string { + return service.deploymentSchedule + ? `Deploy ${service.deploymentSchedule}` + : "Manual Deploy"; +} + +function formatCount(count: number, singular: string): string { + const label = singular + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + + return `${count} ${label}${count === 1 ? "" : "s"}`; +} + function formatCompactNumber(value: number): string { return new Intl.NumberFormat(undefined, { notation: "compact",