[cli] UI URL hints for common CLI commands (#24454)

* Basic implementation for server members and node status

* Commands for alloc status and job status

* -ui flag for most commands

* url hints for variables

* url hints for job dispatch, evals, and deployments

* agent config ui.cli_url_links to disable

* Fix an issue where path prefix was presumed for variables

* driver uncomment and general cleanup

* -ui flag on the generic status endpoint

* Job run command gets namespaces, and no longer gets ui hints for --output flag

* Dispatch command hints get a namespace, and bunch o tests

* Lots of tests depend on specific output, so let's not mess with them

* figured out what flagAddress is all about for testServer, oof

* Parallel outside of test instances

* Browser-opening test, sorta

* Env var for disabling/enabling CLI hints

* Addressing a few PR comments

* CLI docs available flags now all have -ui

* PR comments addressed; switched the env var to be consistent and scrunched monitor-adjacent hints a bit more

* ui.Output -> ui.Warn; moves hints from stdout to stderr

* isTerminal check and parseBool on command option

* terminal.IsTerminal check removed for test-runner-not-being-terminal reasons
This commit is contained in:
Phil Renaud
2025-03-07 13:23:35 -05:00
committed by GitHub
parent f3d53e3e2b
commit 35e1ea4328
33 changed files with 940 additions and 19 deletions

3
.changelog/24454.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
cli: Added UI URL hints to the end of common CLI commands and a `-ui` flag to auto-open them
```

View File

@@ -49,6 +49,9 @@ Alloc Status Options:
-verbose
Show full information.
-ui
Open the allocation status page in the browser.
-json
Output the allocation in its JSON format.
@@ -70,6 +73,7 @@ func (c *AllocStatusCommand) AutocompleteFlags() complete.Flags {
"-verbose": complete.PredictNothing,
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
"-ui": complete.PredictNothing,
})
}
@@ -91,7 +95,7 @@ func (c *AllocStatusCommand) AutocompleteArgs() complete.Predictor {
func (c *AllocStatusCommand) Name() string { return "alloc status" }
func (c *AllocStatusCommand) Run(args []string) int {
var short, displayStats, verbose, json bool
var short, displayStats, verbose, json, openURL bool
var tmpl string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
@@ -101,6 +105,7 @@ func (c *AllocStatusCommand) Run(args []string) int {
flags.BoolVar(&displayStats, "stats", false, "")
flags.BoolVar(&json, "json", false, "")
flags.StringVar(&tmpl, "t", "", "")
flags.BoolVar(&openURL, "ui", false, "")
if err := flags.Parse(args); err != nil {
return 1
@@ -237,6 +242,16 @@ func (c *AllocStatusCommand) Run(args []string) int {
c.Ui.Output(formatAllocMetrics(alloc.Metrics, true, " "))
}
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "alloc status",
PathParams: map[string]string{
"allocID": alloc.ID,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}

View File

@@ -19,6 +19,9 @@ const (
// EnvNomadCLIForceColor is an env var that forces colored UI output.
EnvNomadCLIForceColor = `NOMAD_CLI_FORCE_COLOR`
// EnvNomadCLIShowHints is an env var that toggles CLI hints.
EnvNomadCLIShowHints = `NOMAD_CLI_SHOW_HINTS`
)
// DeprecatedCommand is a command that wraps an existing command and prints a

View File

@@ -55,6 +55,9 @@ Status Options:
How long to wait before polling an update, used in conjunction with monitor
mode. Defaults to 2s.
-ui
Open the deployment in the browser.
-t
Format and display deployment using a Go template.
`
@@ -72,6 +75,7 @@ func (c *DeploymentStatusCommand) AutocompleteFlags() complete.Flags {
"-json": complete.PredictNothing,
"-monitor": complete.PredictNothing,
"-t": complete.PredictAnything,
"-ui": complete.PredictNothing,
})
}
@@ -93,7 +97,7 @@ func (c *DeploymentStatusCommand) AutocompleteArgs() complete.Predictor {
func (c *DeploymentStatusCommand) Name() string { return "deployment status" }
func (c *DeploymentStatusCommand) Run(args []string) int {
var json, verbose, monitor bool
var json, verbose, monitor, openURL bool
var wait time.Duration
var tmpl string
@@ -104,7 +108,7 @@ func (c *DeploymentStatusCommand) Run(args []string) int {
flags.BoolVar(&monitor, "monitor", false, "")
flags.StringVar(&tmpl, "t", "", "")
flags.DurationVar(&wait, "wait", 2*time.Second, "")
flags.BoolVar(&openURL, "ui", false, "")
if err := flags.Parse(args); err != nil {
return 1
}
@@ -183,9 +187,33 @@ func (c *DeploymentStatusCommand) Run(args []string) int {
formatTime(time.Now()), limit(deploy.ID, length)))
c.monitor(client, deploy.ID, meta.LastIndex, wait, verbose)
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "deployment status",
PathParams: map[string]string{
"jobID": deploy.JobID,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
// Because this is before monitor, newline so we don't scrunch
c.Ui.Warn("")
}
return 0
}
c.Ui.Output(c.Colorize().Color(formatDeployment(client, deploy, length)))
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "deployment status",
PathParams: map[string]string{
"jobID": deploy.JobID,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}

View File

@@ -52,6 +52,9 @@ Eval List Options:
-t
Format and display evaluation using a Go template.
-ui
Open the evaluations page in the browser.
`
return strings.TrimSpace(helpText)
@@ -72,6 +75,7 @@ func (c *EvalListCommand) AutocompleteFlags() complete.Flags {
"-status": complete.PredictAnything,
"-per-page": complete.PredictAnything,
"-page-token": complete.PredictAnything,
"-ui": complete.PredictNothing,
})
}
@@ -93,7 +97,7 @@ func (c *EvalListCommand) AutocompleteArgs() complete.Predictor {
func (c *EvalListCommand) Name() string { return "eval list" }
func (c *EvalListCommand) Run(args []string) int {
var monitor, verbose, json bool
var monitor, verbose, json, openURL bool
var perPage int
var tmpl, pageToken, filter, filterJobID, filterStatus string
@@ -103,6 +107,7 @@ func (c *EvalListCommand) Run(args []string) int {
flags.BoolVar(&verbose, "verbose", false, "")
flags.BoolVar(&json, "json", false, "")
flags.StringVar(&tmpl, "t", "", "")
flags.BoolVar(&openURL, "ui", false, "")
flags.IntVar(&perPage, "per-page", 0, "")
flags.StringVar(&pageToken, "page-token", "", "")
flags.StringVar(&filter, "filter", "", "")
@@ -173,6 +178,14 @@ Results have been paginated. To get the next page run:
%s -page-token %s`, argsWithoutPageToken(os.Args), qm.NextToken))
}
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "eval list",
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}

View File

@@ -43,6 +43,9 @@ Eval Status Options:
-t
Format and display evaluation using a Go template.
-ui
Open the evaluation in the browser.
`
return strings.TrimSpace(helpText)
@@ -59,6 +62,7 @@ func (c *EvalStatusCommand) AutocompleteFlags() complete.Flags {
"-monitor": complete.PredictNothing,
"-t": complete.PredictAnything,
"-verbose": complete.PredictNothing,
"-ui": complete.PredictNothing,
})
}
@@ -84,7 +88,7 @@ func (c *EvalStatusCommand) AutocompleteArgs() complete.Predictor {
func (c *EvalStatusCommand) Name() string { return "eval status" }
func (c *EvalStatusCommand) Run(args []string) int {
var monitor, verbose, json bool
var monitor, verbose, json, openURL bool
var tmpl string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
@@ -93,7 +97,7 @@ func (c *EvalStatusCommand) Run(args []string) int {
flags.BoolVar(&verbose, "verbose", false, "")
flags.BoolVar(&json, "json", false, "")
flags.StringVar(&tmpl, "t", "", "")
flags.BoolVar(&openURL, "ui", false, "")
if err := flags.Parse(args); err != nil {
return 1
}
@@ -247,6 +251,17 @@ func (c *EvalStatusCommand) Run(args []string) int {
}
}
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "eval status",
PathParams: map[string]string{
"evalID": eval.ID,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}

View File

@@ -6,6 +6,7 @@ package command
import (
"fmt"
"io"
"net/url"
"os"
"strings"
@@ -68,6 +69,9 @@ Dispatch Options:
-verbose
Display full information.
-ui
Open the dispatched job in the browser.
`
return strings.TrimSpace(helpText)
}
@@ -83,6 +87,7 @@ func (c *JobDispatchCommand) AutocompleteFlags() complete.Flags {
"-detach": complete.PredictNothing,
"-idempotency-token": complete.PredictAnything,
"-verbose": complete.PredictNothing,
"-ui": complete.PredictNothing,
})
}
@@ -113,7 +118,7 @@ func (c *JobDispatchCommand) AutocompleteArgs() complete.Predictor {
func (c *JobDispatchCommand) Name() string { return "job dispatch" }
func (c *JobDispatchCommand) Run(args []string) int {
var detach, verbose bool
var detach, verbose, openURL bool
var idempotencyToken string
var meta []string
var idPrefixTemplate string
@@ -125,6 +130,7 @@ func (c *JobDispatchCommand) Run(args []string) int {
flags.StringVar(&idempotencyToken, "idempotency-token", "", "")
flags.Var((*flaghelper.StringFlag)(&meta), "meta", "")
flags.StringVar(&idPrefixTemplate, "id-prefix-template", "", "")
flags.BoolVar(&openURL, "ui", false, "")
if err := flags.Parse(args); err != nil {
return 1
@@ -220,5 +226,22 @@ func (c *JobDispatchCommand) Run(args []string) int {
c.Ui.Output("")
mon := newMonitor(c.Ui, client, length)
// for hint purposes, need the dispatchedJobID to be escaped ("/" becomes "%2F")
dispatchID := url.PathEscape(resp.DispatchedJobID)
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "job dispatch",
PathParams: map[string]string{
"dispatchID": dispatchID,
"namespace": namespace,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
// Because this is before monitor, newline so we don't scrunch
c.Ui.Warn("")
}
return mon.monitor(resp.EvalID)
}

View File

@@ -97,6 +97,9 @@ Run Options:
Output the JSON that would be submitted to the HTTP API without submitting
the job.
-ui
Open the job page in the browser.
-policy-override
Sets the flag to force override any soft mandatory Sentinel policies.
@@ -147,6 +150,7 @@ func (c *JobRunCommand) AutocompleteFlags() complete.Flags {
"-var": complete.PredictAnything,
"-var-file": complete.PredictFiles("*.var"),
"-eval-priority": complete.PredictNothing,
"-ui": complete.PredictNothing,
})
}
@@ -161,7 +165,7 @@ func (c *JobRunCommand) AutocompleteArgs() complete.Predictor {
func (c *JobRunCommand) Name() string { return "job run" }
func (c *JobRunCommand) Run(args []string) int {
var detach, verbose, output, override, preserveCounts bool
var detach, verbose, output, override, preserveCounts, openURL bool
var checkIndexStr, consulNamespace, vaultNamespace string
var evalPriority int
@@ -180,6 +184,7 @@ func (c *JobRunCommand) Run(args []string) int {
flagSet.Var(&c.JobGetter.Vars, "var", "")
flagSet.Var(&c.JobGetter.VarFiles, "var-file", "")
flagSet.IntVar(&evalPriority, "eval-priority", 0, "")
flagSet.BoolVar(&openURL, "ui", false, "")
if err := flagSet.Parse(args); err != nil {
return 1
@@ -254,6 +259,7 @@ func (c *JobRunCommand) Run(args []string) int {
}
c.Ui.Output(string(buf))
return 0
}
@@ -302,6 +308,11 @@ func (c *JobRunCommand) Run(args []string) int {
evalID := resp.EvalID
jobNamespace := c.Meta.namespace
if jobNamespace == "" {
jobNamespace = "default"
}
// Check if we should enter monitor mode
if detach || periodic || paramjob || multiregion {
c.Ui.Output("Job registration successful")
@@ -321,10 +332,36 @@ func (c *JobRunCommand) Run(args []string) int {
c.Ui.Output("Evaluation ID: " + evalID)
}
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "job run",
PathParams: map[string]string{
"jobID": *job.ID,
"namespace": jobNamespace,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}
// Detach was not specified, so start monitoring
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "job run",
PathParams: map[string]string{
"jobID": *job.ID,
"namespace": jobNamespace,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
// Because this is before monitor, newline so we don't scrunch
c.Ui.Warn("")
}
mon := newMonitor(c.Ui, client, length)
return mon.monitor(evalID)

View File

@@ -28,6 +28,7 @@ type JobStatusCommand struct {
verbose bool
json bool
tmpl string
openURL bool
}
// NamespacedID is a tuple of an ID and a namespace
@@ -73,6 +74,9 @@ Status Options:
-verbose
Display full information.
-ui
Open the job status page in the browser.
`
return strings.TrimSpace(helpText)
}
@@ -88,6 +92,7 @@ func (c *JobStatusCommand) AutocompleteFlags() complete.Flags {
"-evals": complete.PredictNothing,
"-short": complete.PredictNothing,
"-verbose": complete.PredictNothing,
"-ui": complete.PredictNothing,
})
}
@@ -119,6 +124,7 @@ func (c *JobStatusCommand) Run(args []string) int {
flags.BoolVar(&c.json, "json", false, "")
flags.StringVar(&c.tmpl, "t", "", "")
flags.BoolVar(&c.verbose, "verbose", false, "")
flags.BoolVar(&c.openURL, "ui", false, "")
if err := flags.Parse(args); err != nil {
return 1
@@ -159,6 +165,13 @@ func (c *JobStatusCommand) Run(args []string) int {
if len(jobs) == 0 {
// No output if we have no jobs
c.Ui.Output("No running jobs")
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "job status",
OpenURL: c.openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
} else {
if c.json || len(c.tmpl) > 0 {
pairs := make([]NamespacedID, len(jobs))
@@ -182,6 +195,13 @@ func (c *JobStatusCommand) Run(args []string) int {
c.Ui.Output(out)
} else {
c.Ui.Output(createStatusListOutput(jobs, allNamespaces))
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "job status",
OpenURL: c.openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
}
}
return 0
@@ -271,6 +291,17 @@ func (c *JobStatusCommand) Run(args []string) int {
// Exit early
if short {
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "job status single",
PathParams: map[string]string{
"jobID": *job.ID,
"namespace": *job.Namespace,
},
OpenURL: c.openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}
@@ -292,6 +323,18 @@ func (c *JobStatusCommand) Run(args []string) int {
}
}
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "job status single",
PathParams: map[string]string{
"jobID": *job.ID,
"namespace": *job.Namespace,
},
OpenURL: c.openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}

View File

@@ -8,8 +8,10 @@ import (
"fmt"
"os"
"reflect"
"strconv"
"strings"
"github.com/hashicorp/cap/util"
"github.com/hashicorp/cli"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/helper/pointer"
@@ -58,6 +60,8 @@ type Meta struct {
// token is used for ACLs to access privileged information
token string
showCLIHints *bool
caCert string
caPath string
clientCert string
@@ -272,6 +276,16 @@ func (m *Meta) SetupUi(args []string) {
Ui: m.Ui,
}
}
// Check to see if the user has disabled hints via env var.
showCLIHints := os.Getenv(EnvNomadCLIShowHints)
if showCLIHints != "" {
if show, err := strconv.ParseBool(showCLIHints); err == nil {
m.showCLIHints = pointer.Of(show)
} else {
m.Ui.Warn(fmt.Sprintf("Invalid value %q for %s: %v", showCLIHints, EnvNomadCLIShowHints, err))
}
}
}
// FormatWarnings returns a string with the warnings formatted for CLI output.
@@ -451,3 +465,197 @@ type funcVar func(s string) error
func (f funcVar) Set(s string) error { return f(s) }
func (f funcVar) String() string { return "" }
func (f funcVar) IsBoolFlag() bool { return false }
type UIRoute struct {
Path string
Description string
}
type UIHintContext struct {
Command string
PathParams map[string]string
OpenURL bool
}
const (
// Colors and styles
resetter = "\033[0m"
magenta = "\033[35m"
blue = "\033[34m"
bold = "\033[1m"
// Output formatting
uiHintDelimiter = "\n\n==> "
defaultHint = "See more in the Web UI:"
)
var CommandUIRoutes = map[string]UIRoute{
"server members": {
Path: "/servers",
Description: "View and manage Nomad servers",
},
"node status": {
Path: "/clients",
Description: "View and manage Nomad clients",
},
"node status single": {
Path: "/clients/:nodeID",
Description: "View client details and metrics",
},
"job status": {
Path: "/jobs",
Description: "View and manage Nomad jobs",
},
"job status single": {
Path: "/jobs/:jobID@:namespace",
Description: "View job details and metrics",
},
"job run": {
Path: "/jobs/:jobID@:namespace",
Description: "View this job",
},
"alloc status": {
Path: "/allocations/:allocID",
Description: "View allocation details",
},
"var list": {
Path: "/variables",
Description: "View Nomad variables",
},
"var list prefix": {
Path: "/variables/path/:prefix",
Description: "View Nomad variables at this path",
},
"var get": {
Path: "/variables/var/:path@:namespace",
Description: "View variable details",
},
"var put": {
Path: "/variables/var/:path@:namespace",
Description: "View variable details",
},
"job dispatch": {
Path: "/jobs/:dispatchID@:namespace",
Description: "View this job",
},
"eval list": {
Path: "/evaluations",
Description: "View evaluations",
},
"eval status": {
Path: "/evaluations?currentEval=:evalID",
Description: "View evaluation details",
},
"deployment status": {
Path: "/jobs/:jobID/deployments",
Description: "View all deployments for this job",
},
}
func (m *Meta) formatUIHint(url string, description string) string {
if description == "" {
description = defaultHint
}
description = fmt.Sprintf("%s in the Web UI:", description)
// Basic version without colors
hint := fmt.Sprintf("%s%s %s", uiHintDelimiter, description, url)
// If colors are disabled, return basic version
_, coloredUi := m.Ui.(*cli.ColoredUi)
if m.noColor || !coloredUi {
return hint
}
return fmt.Sprintf("%[1]s%[2]s%[3]s%[4]s%[5]s %[6]s%[7]s%[8]s",
bold,
magenta,
uiHintDelimiter[1:], // "==> "
description,
resetter,
blue,
url,
resetter,
)
}
func (m *Meta) buildUIPath(route UIRoute, params map[string]string) (string, error) {
client, err := m.Client()
if err != nil {
return "", fmt.Errorf("error getting client config: %v", err)
}
path := route.Path
for k, v := range params {
path = strings.ReplaceAll(path, fmt.Sprintf(":%s", k), v)
}
return fmt.Sprintf("%s/ui%s", client.Address(), path), nil
}
func (m *Meta) showUIPath(ctx UIHintContext) (string, error) {
route, exists := CommandUIRoutes[ctx.Command]
if !exists {
return "", nil
}
url, err := m.buildUIPath(route, ctx.PathParams)
if err != nil {
return "", err
}
if ctx.OpenURL {
if err := util.OpenURL(url); err != nil {
m.Ui.Warn(fmt.Sprintf("Failed to open browser: %v", err))
}
}
if m.uiHintsDisabled() {
return "", nil
}
return m.formatUIHint(url, route.Description), nil
}
func (m *Meta) uiHintsDisabled() bool {
// Either the local env var is set to false,
// or the agent config is set to false nad the local config isn't set to true
// First check if the user/env var is set to false. If it is, return early.
if m.showCLIHints != nil && !*m.showCLIHints {
return true
}
// Next, check if the agent config is set to false. If it is, return early.
client, err := m.Client()
if err != nil {
return true
}
agent, err := client.Agent().Self()
if err != nil {
return true
}
agentConfig := agent.Config
agentUIConfig, ok := agentConfig["UI"].(map[string]any)
if !ok {
return false
}
agentShowCLIHints, ok := agentUIConfig["ShowCLIHints"].(bool)
if !ok {
return false
}
if !agentShowCLIHints {
// check to see if env var is set to true, overriding the agent setting
if m.showCLIHints != nil && *m.showCLIHints {
return false
}
return true
}
return false
}

View File

@@ -5,6 +5,7 @@ package command
import (
"flag"
"fmt"
"os"
"reflect"
"sort"
@@ -14,6 +15,7 @@ import (
"github.com/hashicorp/cli"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/command/agent"
"github.com/hashicorp/nomad/helper/pointer"
"github.com/shoenig/test/must"
)
@@ -250,3 +252,345 @@ func TestMeta_JobByPrefix(t *testing.T) {
})
}
}
func TestMeta_ShowUIPath(t *testing.T) {
ci.Parallel(t)
// Create a test server with UI enabled but CLI URL links disabled
server, client, url := testServer(t, true, func(c *agent.Config) {
c.UI.ShowCLIHints = pointer.Of(true)
})
defer server.Shutdown()
waitForNodes(t, client)
m := &Meta{
Ui: cli.NewMockUi(),
flagAddress: url,
}
cases := []struct {
name string
context UIHintContext
expectedURL string
expectedOpened bool
}{
{
name: "server members",
context: UIHintContext{
Command: "server members",
},
expectedURL: url + "/ui/servers",
},
{
name: "node status (many)",
context: UIHintContext{
Command: "node status",
},
expectedURL: url + "/ui/clients",
},
{
name: "node status (single)",
context: UIHintContext{
Command: "node status single",
PathParams: map[string]string{
"nodeID": "node-1",
},
},
expectedURL: url + "/ui/clients/node-1",
},
{
name: "job status (many)",
context: UIHintContext{
Command: "job status",
},
expectedURL: url + "/ui/jobs",
},
{
name: "job status (single)",
context: UIHintContext{
Command: "job status single",
PathParams: map[string]string{
"jobID": "example-job",
"namespace": "default",
},
},
expectedURL: url + "/ui/jobs/example-job@default",
},
{
name: "job run (default ns)",
context: UIHintContext{
Command: "job run",
PathParams: map[string]string{
"jobID": "example-job",
"namespace": "default",
},
},
expectedURL: url + "/ui/jobs/example-job@default",
},
{
name: "job run (non-default ns)",
context: UIHintContext{
Command: "job run",
PathParams: map[string]string{
"jobID": "example-job",
"namespace": "prod",
},
},
expectedURL: url + "/ui/jobs/example-job@prod",
},
{
name: "job dispatch (default ns)",
context: UIHintContext{
Command: "job dispatch",
PathParams: map[string]string{
"dispatchID": "dispatch-1",
"namespace": "default",
},
},
expectedURL: url + "/ui/jobs/dispatch-1@default",
},
{
name: "job dispatch (non-default ns)",
context: UIHintContext{
Command: "job dispatch",
PathParams: map[string]string{
"dispatchID": "dispatch-1",
"namespace": "toronto",
},
},
expectedURL: url + "/ui/jobs/dispatch-1@toronto",
},
{
name: "eval list",
context: UIHintContext{
Command: "eval list",
},
expectedURL: url + "/ui/evaluations",
},
{
name: "eval status",
context: UIHintContext{
Command: "eval status",
PathParams: map[string]string{
"evalID": "eval-1",
},
},
expectedURL: url + "/ui/evaluations?currentEval=eval-1",
},
{
name: "deployment status",
context: UIHintContext{
Command: "deployment status",
PathParams: map[string]string{
"jobID": "example-job",
},
},
expectedURL: url + "/ui/jobs/example-job/deployments",
},
{
name: "var list (root)",
context: UIHintContext{
Command: "var list",
},
expectedURL: url + "/ui/variables",
},
{
name: "var list (path)",
context: UIHintContext{
Command: "var list prefix",
PathParams: map[string]string{
"prefix": "foo",
},
},
expectedURL: url + "/ui/variables/path/foo",
},
{
name: "var get",
context: UIHintContext{
Command: "var get",
PathParams: map[string]string{
"path": "foo",
"namespace": "default",
},
},
expectedURL: url + "/ui/variables/var/foo@default",
},
{
name: "var put",
context: UIHintContext{
Command: "var put",
PathParams: map[string]string{
"path": "foo",
"namespace": "default",
},
},
expectedURL: url + "/ui/variables/var/foo@default",
},
{
name: "alloc status",
context: UIHintContext{
Command: "alloc status",
PathParams: map[string]string{
"allocID": "alloc-1",
},
},
expectedURL: url + "/ui/allocations/alloc-1",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
route := CommandUIRoutes[tc.context.Command]
expectedHint := fmt.Sprintf("\n\n==> %s in the Web UI: %s", route.Description, tc.expectedURL)
hint, err := m.showUIPath(tc.context)
must.NoError(t, err)
must.Eq(t, expectedHint, hint)
})
}
}
func TestMeta_ShowUIPath_ShowCLIHintsEnabled(t *testing.T) {
ci.Parallel(t)
// Create a test server with UI enabled and CLI URL links enabled
server, client, url := testServer(t, true, func(c *agent.Config) {
c.UI.ShowCLIHints = pointer.Of(true)
})
defer server.Shutdown()
waitForNodes(t, client)
m := &Meta{
Ui: cli.NewMockUi(),
flagAddress: url,
}
hint, err := m.showUIPath(UIHintContext{
Command: "job status",
})
must.NoError(t, err)
must.StrContains(t, hint, url+"/ui/jobs")
}
func TestMeta_ShowUIPath_ShowCLIHintsDisabled(t *testing.T) {
ci.Parallel(t)
// Create a test server with UI enabled and CLI URL links disabled
server, client, url := testServer(t, true, func(c *agent.Config) {
c.UI.ShowCLIHints = pointer.Of(false)
})
defer server.Shutdown()
waitForNodes(t, client)
m := &Meta{
Ui: cli.NewMockUi(),
flagAddress: url,
}
hint, err := m.showUIPath(UIHintContext{
Command: "job status",
})
must.NoError(t, err)
must.StrNotContains(t, hint, url+"/ui/jobs")
}
func TestMeta_ShowUIPath_EnvVarOverride(t *testing.T) {
testCases := []struct {
name string
envValue string
serverEnabled bool
expectHints bool
}{
{
name: "env var true overrides server false",
envValue: "true",
serverEnabled: false,
expectHints: true,
},
{
name: "env var false overrides server true",
envValue: "false",
serverEnabled: true,
expectHints: false,
},
{
name: "empty env var falls back to server true",
envValue: "",
serverEnabled: true,
expectHints: true,
},
{
name: "empty env var falls back to server false",
envValue: "",
serverEnabled: false,
expectHints: false,
},
}
for _, tc := range testCases {
tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
// Set environment variable
if tc.envValue != "" {
t.Setenv("NOMAD_CLI_SHOW_HINTS", tc.envValue)
}
// Create a test server with UI enabled and CLI hints as per test case
server, client, url := testServer(t, true, func(c *agent.Config) {
c.UI.ShowCLIHints = pointer.Of(tc.serverEnabled)
})
defer server.Shutdown()
waitForNodes(t, client)
m := &Meta{
Ui: cli.NewMockUi(),
flagAddress: url,
}
m.SetupUi([]string{})
hint, err := m.showUIPath(UIHintContext{
Command: "job status",
})
must.NoError(t, err)
if tc.expectHints {
must.StrContains(t, hint, url+"/ui/jobs")
} else {
must.StrNotContains(t, hint, url+"/ui/jobs")
}
})
}
}
func TestMeta_ShowUIPath_BrowserOpening(t *testing.T) {
ci.Parallel(t)
server, client, url := testServer(t, true, func(c *agent.Config) {
c.UI.ShowCLIHints = pointer.Of(true)
})
defer server.Shutdown()
waitForNodes(t, client)
ui := cli.NewMockUi()
m := &Meta{
Ui: ui,
flagAddress: url,
}
hint, err := m.showUIPath(UIHintContext{
Command: "job status",
OpenURL: true,
})
must.NoError(t, err)
must.StrContains(t, hint, url+"/ui/jobs")
// Not a perfect test, but it's a start: make sure showUIPath isn't warning about
// being unable to open the browser.
must.StrNotContains(t, ui.ErrorWriter.String(), "Failed to open browser")
}

View File

@@ -43,6 +43,7 @@ type NodeStatusCommand struct {
pageToken string
filter string
tmpl string
openURL bool
}
func (c *NodeStatusCommand) Help() string {
@@ -92,6 +93,9 @@ Node Status Options:
-filter
Specifies an expression used to filter query results.
-ui
Open the node status page in the browser.
-os
Display operating system name.
@@ -126,6 +130,7 @@ func (c *NodeStatusCommand) AutocompleteFlags() complete.Flags {
"-os": complete.PredictAnything,
"-quiet": complete.PredictAnything,
"-verbose": complete.PredictNothing,
"-ui": complete.PredictNothing,
})
}
@@ -166,6 +171,7 @@ func (c *NodeStatusCommand) Run(args []string) int {
flags.StringVar(&c.filter, "filter", "", "")
flags.IntVar(&c.perPage, "per-page", 0, "")
flags.StringVar(&c.pageToken, "page-token", "", "")
flags.BoolVar(&c.openURL, "ui", false, "")
if err := flags.Parse(args); err != nil {
return 1
@@ -312,6 +318,14 @@ Results have been paginated. To get the next page run:
%s -page-token %s`, argsWithoutPageToken(os.Args), qm.NextToken))
}
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: c.Name(),
OpenURL: c.openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}
@@ -369,6 +383,7 @@ Results have been paginated. To get the next page run:
}
return c.formatNode(client, node)
}
func nodeDrivers(n *api.Node) []string {
@@ -504,6 +519,17 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
basic = append(basic, fmt.Sprintf("Drivers|%s", strings.Join(nodeDrivers(node), ",")))
c.Ui.Output(c.Colorize().Color(formatKV(basic)))
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "node status single",
PathParams: map[string]string{
"nodeID": node.ID,
},
OpenURL: c.openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
// Output alloc info
if err := c.outputAllocInfo(node, nodeAllocs); err != nil {
c.Ui.Error(fmt.Sprintf("%s", err))
@@ -591,6 +617,17 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
return 1
}
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "node status single",
PathParams: map[string]string{
"nodeID": node.ID,
},
OpenURL: c.openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}

View File

@@ -39,6 +39,9 @@ Server Members Options:
Show detailed information about each member. This dumps a raw set of tags
which shows more information than the default output format.
-ui
Open the servers page in the browser.
-json
Output the latest information about each member in a JSON format.
@@ -55,6 +58,7 @@ func (c *ServerMembersCommand) AutocompleteFlags() complete.Flags {
"-verbose": complete.PredictNothing,
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
"-ui": complete.PredictNothing,
})
}
@@ -69,7 +73,7 @@ func (c *ServerMembersCommand) Synopsis() string {
func (c *ServerMembersCommand) Name() string { return "server members" }
func (c *ServerMembersCommand) Run(args []string) int {
var detailed, verbose, json bool
var detailed, verbose, json, openURL bool
var tmpl string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
@@ -77,6 +81,7 @@ func (c *ServerMembersCommand) Run(args []string) int {
flags.BoolVar(&detailed, "detailed", false, "Show detailed output")
flags.BoolVar(&verbose, "verbose", false, "Show detailed output")
flags.BoolVar(&json, "json", false, "")
flags.BoolVar(&openURL, "ui", false, "Open the servers page in the browser")
flags.StringVar(&tmpl, "t", "", "")
if err := flags.Parse(args); err != nil {
@@ -146,6 +151,14 @@ func (c *ServerMembersCommand) Run(args []string) int {
// Dump the list
c.Ui.Output(columnize.SimpleFormat(out))
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: c.Name(),
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
// If there were leader errors display a warning
if leaderErr != nil {
c.Ui.Output("")

View File

@@ -17,6 +17,7 @@ type StatusCommand struct {
// Placeholder bool to allow passing of verbose flags to subcommands.
verbose bool
openURL bool
}
func (c *StatusCommand) Help() string {
@@ -38,6 +39,9 @@ Status Options:
-verbose
Display full information.
-ui
Open the status page in the browser.
`
return strings.TrimSpace(helpText)
@@ -51,6 +55,7 @@ func (c *StatusCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-verbose": complete.PredictNothing,
"-ui": complete.PredictNothing,
})
}
@@ -84,7 +89,7 @@ func (c *StatusCommand) Run(args []string) int {
flags := c.Meta.FlagSet("status", FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&c.verbose, "verbose", false, "")
flags.BoolVar(&c.openURL, "ui", false, "")
if err := flags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing arguments: %q", err))
return 1

View File

@@ -25,6 +25,9 @@ func testServer(t *testing.T, runClient bool, cb func(*agent.Config)) (*agent.Te
a := agent.NewTestAgent(t, t.Name(), func(config *agent.Config) {
config.Client.Enabled = runClient
// Disable UI hints in test by default
config.UI.ShowCLIHints = pointer.Of(false)
if cb != nil {
cb(config)
}

View File

@@ -49,6 +49,9 @@ Get Options:
-template
Template to render output with. Required when output is "go-template".
-ui
Open the variable page in the browser.
`
return strings.TrimSpace(helpText)
}
@@ -58,6 +61,7 @@ func (c *VarGetCommand) AutocompleteFlags() complete.Flags {
complete.Flags{
"-out": complete.PredictSet("go-template", "hcl", "json", "none", "table"),
"-template": complete.PredictAnything,
"-ui": complete.PredictNothing,
},
)
}
@@ -74,12 +78,13 @@ func (c *VarGetCommand) Name() string { return "var get" }
func (c *VarGetCommand) Run(args []string) int {
var out, item string
var openURL bool
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.StringVar(&item, "item", "", "")
flags.StringVar(&c.tmpl, "template", "", "")
flags.BoolVar(&openURL, "ui", false, "")
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
flags.StringVar(&c.outFmt, "out", "table", "")
@@ -161,10 +166,35 @@ func (c *VarGetCommand) Run(args []string) int {
default:
// the renderSVAsUiTable func writes directly to the ui and doesn't error.
renderSVAsUiTable(sv, c)
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "var put",
PathParams: map[string]string{
"path": path,
"namespace": sv.Namespace,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}
c.Ui.Output(out)
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "var get",
PathParams: map[string]string{
"path": path,
"namespace": sv.Namespace,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}

View File

@@ -65,6 +65,9 @@ List Options:
Template to render output with. Required when format is "go-template",
invalid for other formats.
-ui
Open the variable list page in the browser.
`
return strings.TrimSpace(helpText)
}
@@ -74,6 +77,7 @@ func (c *VarListCommand) AutocompleteFlags() complete.Flags {
complete.Flags{
"-out": complete.PredictSet("go-template", "json", "terse", "table"),
"-template": complete.PredictAnything,
"-ui": complete.PredictNothing,
},
)
}
@@ -90,6 +94,7 @@ func (c *VarListCommand) Name() string { return "var list" }
func (c *VarListCommand) Run(args []string) int {
var perPage int
var pageToken, filter, prefix string
var openURL bool
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
@@ -98,6 +103,7 @@ func (c *VarListCommand) Run(args []string) int {
flags.IntVar(&perPage, "per-page", 0, "")
flags.StringVar(&pageToken, "page-token", "", "")
flags.StringVar(&filter, "filter", "", "")
flags.BoolVar(&openURL, "ui", false, "")
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
flags.StringVar(&c.outFmt, "out", "table", "")
@@ -211,6 +217,27 @@ func (c *VarListCommand) Run(args []string) int {
c.Ui.Warn(fmt.Sprintf("Next page token: %s", qm.NextToken))
}
if prefix != "" {
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "var list prefix",
PathParams: map[string]string{
"prefix": prefix,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
} else {
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "var list",
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
}
return 0
}

View File

@@ -102,6 +102,9 @@ Var put Options:
Provides additional information via standard error to preserve standard
output (stdout) for redirected output.
-ui
Open the variable page in the browser.
`
return strings.TrimSpace(helpText)
}
@@ -111,6 +114,7 @@ func (c *VarPutCommand) AutocompleteFlags() complete.Flags {
complete.Flags{
"-in": complete.PredictSet("hcl", "json"),
"-out": complete.PredictSet("none", "hcl", "json", "go-template", "table"),
"-ui": complete.PredictNothing,
},
)
}
@@ -126,7 +130,7 @@ func (c *VarPutCommand) Synopsis() string {
func (c *VarPutCommand) Name() string { return "var put" }
func (c *VarPutCommand) Run(args []string) int {
var force, enforce, doVerbose bool
var force, enforce, doVerbose, openURL bool
var path, checkIndexStr string
var checkIndex uint64
var err error
@@ -139,7 +143,7 @@ func (c *VarPutCommand) Run(args []string) int {
flags.StringVar(&checkIndexStr, "check-index", "", "")
flags.StringVar(&c.inFmt, "in", "json", "")
flags.StringVar(&c.tmpl, "template", "", "")
flags.BoolVar(&openURL, "ui", false, "")
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
flags.StringVar(&c.outFmt, "out", "none", "")
} else {
@@ -355,13 +359,46 @@ func (c *VarPutCommand) Run(args []string) int {
// the renderSVAsUiTable func writes directly to the ui and doesn't error.
verbose(successMsg)
renderSVAsUiTable(sv, c)
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "var put",
PathParams: map[string]string{
"path": path,
"namespace": sv.Namespace,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
default:
c.Ui.Output(successMsg)
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "var put",
PathParams: map[string]string{
"path": path,
"namespace": sv.Namespace,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}
verbose(successMsg)
c.Ui.Output(out)
hint, _ := c.Meta.showUIPath(UIHintContext{
Command: "var put",
PathParams: map[string]string{
"path": path,
"namespace": sv.Namespace,
},
OpenURL: openURL,
})
if hint != "" {
c.Ui.Warn(hint)
}
return 0
}

View File

@@ -7,6 +7,8 @@ import (
"fmt"
"slices"
"strings"
"github.com/hashicorp/nomad/helper/pointer"
)
// UIConfig contains the operator configuration of the web UI
@@ -28,6 +30,9 @@ type UIConfig struct {
// Label configures UI label styles
Label *LabelUIConfig `hcl:"label"`
// ShowCLIHints controls whether CLI commands that return URLs will output that url as a hint
ShowCLIHints *bool `hcl:"show_cli_hints"`
}
// only covers the elements of
@@ -142,6 +147,7 @@ func DefaultUIConfig() *UIConfig {
Vault: &VaultUIConfig{},
Label: &LabelUIConfig{},
ContentSecurityPolicy: DefaultCSPConfig(),
ShowCLIHints: pointer.Of(true),
}
}
@@ -177,6 +183,10 @@ func (old *UIConfig) Merge(other *UIConfig) *UIConfig {
result.Label = result.Label.Merge(other.Label)
result.ContentSecurityPolicy = result.ContentSecurityPolicy.Merge(other.ContentSecurityPolicy)
if other.ShowCLIHints != nil {
result.ShowCLIHints = other.ShowCLIHints
}
return result
}

View File

@@ -35,6 +35,7 @@ When ACLs are enabled, this command requires a token with the `read-job` and
- `-verbose`: Show full information.
- `-json` : Output the allocation in its JSON format.
- `-t` : Format and display the allocation using a Go template.
- `-ui` : Open the allocation status page in the browser.
## Examples

View File

@@ -40,6 +40,7 @@ capability for the deployment's namespace.
- `-monitor`: Enter monitor mode to poll for updates to the deployment status.
- `-wait`: How long to wait before polling an update, used in conjunction with monitor
mode. Defaults to 2s.
- `-ui`: Open the deployment page in the browser.
## Examples
@@ -135,8 +136,8 @@ $ nomad deployment status -monitor e45
web false 1 1 1 0 2021-06-09T15:59:58-07:00
```
**Please note**: The library used for updating terminal output in place currently isn't fully
Windows compatible so there may be some formatting differences (different margins, no spinner
**Please note**: The library used for updating terminal output in place currently isn't fully
Windows compatible so there may be some formatting differences (different margins, no spinner
indicating deployment is in progress).
[`auto_revert`]: /nomad/docs/job-specification/update#auto_revert
[`auto_revert`]: /nomad/docs/job-specification/update#auto_revert

View File

@@ -34,6 +34,7 @@ capability for the requested namespace.
- `-status`: Only show evaluations with this status.
- `-json`: Output the evaluation in its JSON format.
- `-t`: Format and display evaluation using a Go template.
- `-ui`: Open the evaluations page in the browser.
## Examples

View File

@@ -50,6 +50,7 @@ indicated by exit code 1.
-json`. In Nomad 1.4.0 the behavior of this option will change to
output only the selected evaluation in JSON.
- `-t` : Format and display evaluation using a Go template.
- `-ui`: Open the evaluation in the browser.
## Examples

View File

@@ -94,6 +94,8 @@ flags.
- `NOMAD_CLI_NO_COLOR` - Disables colored command output.
- `NOMAD_CLI_SHOW_HINTS` - Enables ui-hints in common CLI command output.
#### mTLS Environment Variables
- `NOMAD_CLIENT_CERT` - Path to a PEM encoded client certificate for TLS

View File

@@ -76,6 +76,8 @@ dispatching parameterized jobs.
- `-verbose`: Show full information.
- `-ui`: Open the dispatched job in the browser.
## Examples
Dispatch against a parameterized job with the ID "video-encode" and

View File

@@ -97,6 +97,8 @@ that volume.
- `-verbose`: Show full information.
- `-ui`: Open the job page in the browser.
## Examples
Schedule the job contained in the file `example.nomad.hcl`, monitoring placement and deployment:

View File

@@ -53,6 +53,8 @@ run the command with a job prefix instead of the exact job ID.
- `-verbose`: Show full information. Allocation create and modify times are
shown in `yyyy/mm/dd hh:mm:ss` format.
- `-ui`: Open the job status page in the browser.
## Examples
List of all jobs:

View File

@@ -61,6 +61,8 @@ capability.
- `-t` : Format and display node using a Go template.
- `-ui` : Open the node status page in the browser
## Examples
List view:

View File

@@ -40,6 +40,8 @@ capability.
- `-t`: Format and display the server memebers using a Go template.
- `-ui`: Open the servers page in the browser.
## Examples
Default view:
@@ -101,4 +103,4 @@ Or use the `-t` flag to format and display the server members information using
```shell-session
$ nomad server members -t '{{range .}}{{printf "%s: %s" .Name .Status }}{{end}}'
bacon-mac.global: alive
```
```

View File

@@ -39,6 +39,8 @@ documentation for details.
- `-template` `(string: "")` Template to render output with. Required when
output is "go-template".
- `-ui`: Open the variable page in the browser.
## Examples
Retrieve the variable stored at path "secret/creds":

View File

@@ -52,6 +52,8 @@ documentation for details.
- `-template` `(string: "")` Template to render output with. Required when
output is "go-template".
- `-ui`: Open the variable list page in the browser.
## Examples
List values under the key "nomad/jobs":

View File

@@ -83,6 +83,8 @@ taking the sum of the length in bytes of all of the unencrypted keys and values.
- `-verbose`: Provides additional information via standard error to preserve
standard output (stdout) for redirected output.
- `-ui`: Open the variable page in the browser.
## Examples
Writes the data to the path "secret/creds":

View File

@@ -60,6 +60,11 @@ and the configuration is individual to each agent.
- `label` <code>([Label]: nil)</code> - Configures a user-defined
label to display in the Nomad Web UI header.
- `show_cli_hints` `(bool: true)` - Controls whether CLI commands display hints
about equivalent UI pages. For example, when running `nomad server members`,
the CLI shows a message indicating where to find server information in
the web UI. Set to `false` to disable these hints.
## `content_security_policy` Parameters
The `content_security_policy` block configures the HTTP
@@ -108,10 +113,10 @@ header sent with the web UI response.
- `text` `(string: "")` - Specifies the text of the label that will be
displayed in the header of the Web UI.
- `background_color` `(string: "")` - The background color of the label to
be displayed. The Web UI will default to a black background. HEX values
be displayed. The Web UI defaults to a black background. HEX values
may be used.
- `text_color` `(string: "")` - The text color of the label to be displayed.
The Web UI will default to white text. HEX values may be used.
The Web UI defaults to white text. HEX values may be used.