[feat] Nomad Job Templates (#15746)
* Extend variables under the nomad path prefix to allow for job-templates (#15570) * Extend variables under the nomad path prefix to allow for job-templates * Add job-templates to error message hinting * RadioCard component for Job Templates (#15582) * chore: add * test: component API * ui: component template * refact: remove bc naming collission * styles: remove SASS var causing conflicts * Disallow specific variable at nomad/job-templates (#15681) * Disallows variables at exactly nomad/job-templates * idiomatic refactor * Expanding nomad job init to accept a template flag (#15571) * Adding a string flag for templates on job init * data-down actions-up version of a custom template editor within variable * Dont force grid on job template editor * list-templates flag started * Correctly slice from end of path name * Pre-review cleanup * Variable form acceptance test for job template editing * Some review cleanup * List Job templates test * Example from template test * Using must.assertions instead of require etc * ui: add choose template button (#15596) * ui: add new routes * chore: update file directory * ui: add choose template button * test: button and page navigation * refact: update var name * ui: use `Button` component from `HDS` (#15607) * ui: integrate buttons * refact: remove helper * ui: remove icons on non-tertiary buttons * refact: update normalize method for key/value pairs (#15612) * `revert`: `onCancel` for `JobDefinition` The `onCancel` method isn't included in the component API for `JobEditor` and the primary cancel behavior exists outside of the component. With the exception of the `JobDefinition` page where we include this button in the top right of the component instead of next to the `Plan` button. * style: increase button size * style: keep lime green * ui: select template (#15613) * ui: deprecate unused component * ui: deprecate tests * ui: jobs.run.templates.index * ui: update logic to handle templates * refact: revert key/value changes * style: padding for cards + buttons * temp: fixtures for mirage testing * Revert "refact: revert key/value changes" This reverts commit 124e95d12140be38fc921f7e15243034092c4063. * ui: guard template for unsaved job * ui: handle reading template variable * Revert "refact: update normalize method for key/value pairs (#15612)" This reverts commit 6f5ffc9b610702aee7c47fbff742cc81f819ab74. * revert: remove test fixtures * revert: prettier problems * refact: test doesnt need filter expression * styling: button sizes and responsive cards * refact: remove route guarding * ui: update variable adapter * refact: remove model editing behavior * refact: model should query variables to populate editor * ui: clear qp on exit * refact: cleanup deprecated API * refact: query all namespaces * refact: deprecate action * ui: rely on collection * refact: patch deprecate transition API * refact: patch test to expect namespace qp * styling: padding, conditionals * ui: flashMessage on 404 * test: update for o(n+1) query * ui: create new job template (#15744) * refact: remove unused code * refact: add type safety * test: select template flow * test: add data-test attrs * chore: remove dead code * test: create new job flow * ui: add create button * ui: create job template * refact: no need for wildcard * refact: record instead of delete * styling: spacing * ui: add error handling and form validation to job create template (#15767) * ui: handle server side errors * ui: show error to prevent duplicate * refact: conditional namespace * ui: save as template flow (#15787) * bug: patches failing tests associated with `pretender` (#15812) * refact: update assertion * refact: test set-up * ui: job templates manager view (#15815) * ui: manager list view * test: edit flow * refact: deprecate column-helper * ui: template edit and delete flow (#15823) * ui: manager list view * refact: update title * refact: update permissions * ui: template edit page * bug: typo * refact: update toast messages * bug: clear selections on exit (#15827) * bug: clear controllers on exit * test: mirage config changes (#15828) * refact: deprecate column-helper * style: update z-index for HDS * Revert "style: update z-index for HDS" This reverts commit d3d87ceab6d083f7164941587448607838944fc1. * refact: update delete button * refact: edit redirect * refact: patch reactivity issues * styling: fixed width * refact: override defaults * styling: edit text causing overflow * styling: add inline text Co-authored-by: Phil Renaud <phil.renaud@hashicorp.com> * bug: edit `text` to `template` Co-authored-by: Phil Renaud <phil.renaud@hashicorp.com> Co-authored-by: Phil Renaud <phil.renaud@hashicorp.com> * test: delete flow job templates (#15896) * refact: edit names * bug: set correct ref to store * chore: trim whitespace: * test: delete flow * bug: reactively update view (#15904) * Initialized default jobs (#15856) * Initialized default jobs * More jobs scaffolded * Better commenting on a couple example job specs * Adapter doing the work * fall back to epic config * Label format helper and custom serialization logic * Test updates to account for a never-empty state * Test suite uses settled and maintain RecordArray in adapter return * Updates to hello-world and variables example jobspecs * Parameterized job gets optional payload output * Formatting changes for param and service discovery job templates * Multi-group service discovery job * Basic test for default templates (#15965) * Basic test for default templates * Percy snapshot for manage page * Some late-breaking design changes * Some copy edits to the header paragraphs for job templates (#15967) * Added some init options for job templates (#15994) * Async method for populating default job templates from the variable adapter --------- Co-authored-by: Jai <41024828+ChaiWithJai@users.noreply.github.com>
This commit is contained in:
parent
4caac1a92f
commit
3db9f11c37
|
@ -0,0 +1,3 @@
|
|||
```release-note:improvement
|
||||
ui, cli: Adds Job Templates to the "Run Job" Web UI and makes them accessible via new flags on nomad job init
|
||||
```
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/nomad/api"
|
||||
"github.com/posener/complete"
|
||||
)
|
||||
|
||||
|
@ -36,6 +37,14 @@ Init Options:
|
|||
|
||||
-connect
|
||||
If the connect flag is set, the jobspec includes Consul Connect integration.
|
||||
|
||||
-template
|
||||
Specifies a predefined template to initialize. Must be a Nomad Variable that
|
||||
lives at nomad/job-templates/<template>
|
||||
|
||||
-list-templates
|
||||
Display a list of possible job templates to pass to -template. Reads from
|
||||
all variables pathed at nomad/job-templates/<template>
|
||||
`
|
||||
return strings.TrimSpace(helpText)
|
||||
}
|
||||
|
@ -60,11 +69,15 @@ func (c *JobInitCommand) Name() string { return "job init" }
|
|||
func (c *JobInitCommand) Run(args []string) int {
|
||||
var short bool
|
||||
var connect bool
|
||||
var template string
|
||||
var listTemplates bool
|
||||
|
||||
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
|
||||
flags.Usage = func() { c.Ui.Output(c.Help()) }
|
||||
flags.BoolVar(&short, "short", false, "")
|
||||
flags.BoolVar(&connect, "connect", false, "")
|
||||
flags.StringVar(&template, "template", "", "The name of the job template variable to initialize")
|
||||
flags.BoolVar(&listTemplates, "list-templates", false, "")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 1
|
||||
|
@ -90,27 +103,87 @@ func (c *JobInitCommand) Run(args []string) int {
|
|||
c.Ui.Error(fmt.Sprintf("Failed to stat '%s': %v", filename, err))
|
||||
return 1
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
if !os.IsNotExist(err) && !listTemplates {
|
||||
c.Ui.Error(fmt.Sprintf("Job '%s' already exists", filename))
|
||||
return 1
|
||||
}
|
||||
|
||||
var jobSpec []byte
|
||||
switch {
|
||||
case connect && !short:
|
||||
jobSpec, err = Asset("command/assets/connect.nomad")
|
||||
case connect && short:
|
||||
jobSpec, err = Asset("command/assets/connect-short.nomad")
|
||||
case !connect && short:
|
||||
jobSpec, err = Asset("command/assets/example-short.nomad")
|
||||
default:
|
||||
jobSpec, err = Asset("command/assets/example.nomad")
|
||||
}
|
||||
if err != nil {
|
||||
// should never see this because we've precompiled the assets
|
||||
// as part of `make generate-examples`
|
||||
c.Ui.Error(fmt.Sprintf("Accessed non-existent asset: %s", err))
|
||||
return 1
|
||||
|
||||
if listTemplates {
|
||||
// Get the HTTP client
|
||||
client, err := c.Meta.Client()
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
|
||||
return 1
|
||||
}
|
||||
qo := &api.QueryOptions{
|
||||
Namespace: c.Meta.namespace,
|
||||
}
|
||||
|
||||
// Get and list all variables at nomad/job-templates
|
||||
vars, _, err := client.Variables().PrefixList("nomad/job-templates/", qo)
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Error retrieving job templates from the server; unable to read variables at path nomad/job-templates/. Error: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
if len(vars) == 0 {
|
||||
c.Ui.Error("No variables in nomad/job-templates")
|
||||
return 1
|
||||
} else {
|
||||
c.Ui.Output("Use nomad job init -template=<template> with any of the following:")
|
||||
for _, v := range vars {
|
||||
c.Ui.Output(fmt.Sprintf(" %s", strings.TrimPrefix(v.Path, "nomad/job-templates/")))
|
||||
}
|
||||
}
|
||||
return 0
|
||||
} else if template != "" {
|
||||
// Get the HTTP client
|
||||
client, err := c.Meta.Client()
|
||||
if err != nil {
|
||||
c.Ui.Error(fmt.Sprintf("Error initializing: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
qo := &api.QueryOptions{
|
||||
Namespace: c.Meta.namespace,
|
||||
}
|
||||
sv, _, err := client.Variables().Read("nomad/job-templates/"+template, qo)
|
||||
if err != nil {
|
||||
if err.Error() == "variable not found" {
|
||||
c.Ui.Warn(errVariableNotFound)
|
||||
return 1
|
||||
}
|
||||
c.Ui.Error(fmt.Sprintf("Error retrieving variable: %s", err))
|
||||
return 1
|
||||
}
|
||||
|
||||
if v, ok := sv.Items["template"]; ok {
|
||||
c.Ui.Output(fmt.Sprintf("Initializing a job template from %s", template))
|
||||
jobSpec = []byte(v)
|
||||
} else {
|
||||
c.Ui.Error(fmt.Sprintf("Job template %q is malformed and is missing a template field. Please visit the jobs/run/templates route in the Nomad UI to add it", template))
|
||||
return 1
|
||||
}
|
||||
|
||||
} else {
|
||||
switch {
|
||||
case connect && !short:
|
||||
jobSpec, err = Asset("command/assets/connect.nomad")
|
||||
case connect && short:
|
||||
jobSpec, err = Asset("command/assets/connect-short.nomad")
|
||||
case !connect && short:
|
||||
jobSpec, err = Asset("command/assets/example-short.nomad")
|
||||
default:
|
||||
jobSpec, err = Asset("command/assets/example.nomad")
|
||||
}
|
||||
if err != nil {
|
||||
// should never see this because we've precompiled the assets
|
||||
// as part of `make generate-examples`
|
||||
c.Ui.Error(fmt.Sprintf("Accessed non-existent asset: %s", err))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// Write out the example
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
|
@ -8,6 +9,7 @@ import (
|
|||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/mitchellh/cli"
|
||||
"github.com/shoenig/test/must"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
|
@ -86,6 +88,84 @@ func TestInitCommand_defaultJob(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestInitCommand_listTemplates(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
srv, _, url := testServer(t, true, nil)
|
||||
defer srv.Shutdown()
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
|
||||
jobCmd := &JobInitCommand{Meta: Meta{Ui: ui}}
|
||||
jobCmd.Run([]string{"-address=" + url, "-list-templates"})
|
||||
expectedOutput := "No variables in nomad/job-templates\n"
|
||||
must.StrContains(t, ui.ErrorWriter.String(), expectedOutput)
|
||||
|
||||
varCmd := &VarPutCommand{Meta: Meta{Ui: ui}}
|
||||
// Set up 3 job template variables
|
||||
for i := 1; i <= 3; i++ {
|
||||
templateName := fmt.Sprintf("template-%d", i)
|
||||
must.Eq(t, 0, varCmd.Run([]string{"-address=" + url, "-out=json", "nomad/job-templates/" + templateName, "k1=v1", "k2=v2", "k3=v3"}))
|
||||
}
|
||||
ui.ErrorWriter.Reset()
|
||||
ui.OutputWriter.Reset()
|
||||
|
||||
jobCmd = &JobInitCommand{Meta: Meta{Ui: ui}}
|
||||
must.Eq(t, 0, jobCmd.Run([]string{"-address=" + url, "-list-templates"}))
|
||||
expectedOutput = "Use nomad job init -template=<template> with any of the following:\n template-1\n template-2\n template-3\n"
|
||||
must.StrContains(t, ui.OutputWriter.String(), expectedOutput)
|
||||
}
|
||||
|
||||
func TestInitCommand_fromJobTemplate(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
srv, _, url := testServer(t, true, nil)
|
||||
defer srv.Shutdown()
|
||||
|
||||
ui := cli.NewMockUi()
|
||||
varCmd := &VarPutCommand{Meta: Meta{Ui: ui}}
|
||||
|
||||
tinyJob := `job "tiny" {
|
||||
group "foo" {
|
||||
task "bar" {
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// Set up job template variables
|
||||
varCmd.Run([]string{"-address=" + url, "-out=json", "nomad/job-templates/invalid-template", "k1=v1"})
|
||||
varCmd.Run([]string{"-address=" + url, "-out=json", "nomad/job-templates/valid-template", "template=" + tinyJob})
|
||||
ui.ErrorWriter.Reset()
|
||||
ui.OutputWriter.Reset()
|
||||
|
||||
// Ensure we change the cwd back
|
||||
origDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
// Create a temp dir and change into it
|
||||
dir := t.TempDir()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
jobCmd := &JobInitCommand{Meta: Meta{Ui: ui}}
|
||||
|
||||
// Doesnt work if our var lacks a template key
|
||||
must.Eq(t, 1, jobCmd.Run([]string{"-address=" + url, "-template=invalid-template"}))
|
||||
|
||||
// Works if the file doesn't exist
|
||||
must.Eq(t, 0, jobCmd.Run([]string{"-address=" + url, "-template=valid-template"}))
|
||||
|
||||
content, err := ioutil.ReadFile(DefaultInitName)
|
||||
must.NoError(t, err)
|
||||
must.Eq(t, string(content), string(tinyJob))
|
||||
|
||||
ui.ErrorWriter.Reset()
|
||||
expectedOutput := "Initializing a job template from valid-template\nExample job file written to example.nomad\n"
|
||||
must.StrContains(t, ui.OutputWriter.String(), expectedOutput)
|
||||
}
|
||||
|
||||
func TestInitCommand_customFilename(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
ui := cli.NewMockUi()
|
||||
|
|
|
@ -157,19 +157,8 @@ var (
|
|||
|
||||
func (v VariableDecrypted) Validate() error {
|
||||
|
||||
if len(v.Path) == 0 {
|
||||
return fmt.Errorf("variable requires path")
|
||||
}
|
||||
if !validVariablePath.MatchString(v.Path) {
|
||||
return fmt.Errorf("invalid path %q", v.Path)
|
||||
}
|
||||
|
||||
parts := strings.Split(v.Path, "/")
|
||||
switch {
|
||||
case len(parts) == 1 && parts[0] == "nomad":
|
||||
return fmt.Errorf("\"nomad\" is a reserved top-level directory path, but you may write variables to \"nomad/jobs\" or below")
|
||||
case len(parts) >= 2 && parts[0] == "nomad" && parts[1] != "jobs":
|
||||
return fmt.Errorf("only paths at \"nomad/jobs\" or below are valid paths under the top-level \"nomad\" directory")
|
||||
if v.Namespace == AllNamespacesSentinel {
|
||||
return errors.New("can not target wildcard (\"*\")namespace")
|
||||
}
|
||||
|
||||
if len(v.Items) == 0 {
|
||||
|
@ -178,12 +167,49 @@ func (v VariableDecrypted) Validate() error {
|
|||
if v.Items.Size() > maxVariableSize {
|
||||
return errors.New("variables are limited to 64KiB in total size")
|
||||
}
|
||||
if v.Namespace == AllNamespacesSentinel {
|
||||
return errors.New("can not target wildcard (\"*\")namespace")
|
||||
|
||||
if err := validatePath(v.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePath(path string) error {
|
||||
if len(path) == 0 {
|
||||
return fmt.Errorf("variable requires path")
|
||||
}
|
||||
if !validVariablePath.MatchString(path) {
|
||||
return fmt.Errorf("invalid path %q", path)
|
||||
}
|
||||
|
||||
parts := strings.Split(path, "/")
|
||||
|
||||
if parts[0] != "nomad" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Don't allow a variable with path "nomad"
|
||||
if len(parts) == 1 {
|
||||
return fmt.Errorf("\"nomad\" is a reserved top-level directory path, but you may write variables to \"nomad/jobs\", \"nomad/job-templates\", or below")
|
||||
}
|
||||
|
||||
switch {
|
||||
case parts[1] == "jobs":
|
||||
// Any path including "nomad/jobs" is valid
|
||||
return nil
|
||||
case parts[1] == "job-templates" && len(parts) == 3:
|
||||
// Paths including "nomad/job-templates" is valid, provided they have single further path part
|
||||
return nil
|
||||
case parts[1] == "job-templates":
|
||||
// Disallow exactly nomad/job-templates with no further paths
|
||||
return fmt.Errorf("\"nomad/job-templates\" is a reserved directory path, but you may write variables at the level below it, for example, \"nomad/job-templates/template-name\"")
|
||||
default:
|
||||
// Disallow arbitrary sub-paths beneath nomad/
|
||||
return fmt.Errorf("only paths at \"nomad/jobs\" or \"nomad/job-templates\" and below are valid paths under the top-level \"nomad\" directory")
|
||||
}
|
||||
}
|
||||
|
||||
func (sv *VariableDecrypted) Canonicalize() {
|
||||
if sv.Namespace == "" {
|
||||
sv.Namespace = DefaultNamespace
|
||||
|
|
|
@ -54,6 +54,8 @@ func TestStructs_VariableDecrypted_Validate(t *testing.T) {
|
|||
{path: "example/_-~/whatever", ok: true},
|
||||
{path: "example/@whatever"},
|
||||
{path: "example/what.ever"},
|
||||
{path: "nomad/job-templates"},
|
||||
{path: "nomad/job-templates/whatever", ok: true},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
// @ts-check
|
||||
import ApplicationAdapter from './application';
|
||||
import AdapterError from '@ember-data/adapter/error';
|
||||
import { pluralize } from 'ember-inflector';
|
||||
import classic from 'ember-classic-decorator';
|
||||
import { ConflictError } from '@ember-data/adapter/error';
|
||||
import DEFAULT_JOB_TEMPLATES from 'nomad-ui/utils/default-job-templates';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
@classic
|
||||
export default class VariableAdapter extends ApplicationAdapter {
|
||||
@service store;
|
||||
|
||||
pathForType = () => 'var';
|
||||
|
||||
// PUT instead of POST on create;
|
||||
|
@ -16,6 +22,65 @@ export default class VariableAdapter extends ApplicationAdapter {
|
|||
return this.ajax(`${baseUrl}?cas=${checkAndSetValue}`, 'PUT', { data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Query for job templates, both defaults and variables at the nomad/job-templates path.
|
||||
* @returns {Promise<{variables: Variable[], default: Variable[]}>}
|
||||
*/
|
||||
async getJobTemplates() {
|
||||
await this.populateDefaultJobTemplates();
|
||||
const jobTemplateVariables = await this.store.query('variable', {
|
||||
prefix: 'nomad/job-templates',
|
||||
namespace: '*',
|
||||
});
|
||||
|
||||
// Ensure we run a findRecord on each to get its keyValues
|
||||
await Promise.all(
|
||||
jobTemplateVariables.map((t) => this.store.findRecord('variable', t.id))
|
||||
);
|
||||
|
||||
const defaultTemplates = this.store
|
||||
.peekAll('variable')
|
||||
.filter((t) => t.isDefaultJobTemplate);
|
||||
|
||||
return { variables: jobTemplateVariables, default: defaultTemplates };
|
||||
}
|
||||
|
||||
async populateDefaultJobTemplates() {
|
||||
await Promise.all(
|
||||
DEFAULT_JOB_TEMPLATES.map((template) => {
|
||||
if (!this.store.peekRecord('variable', template.id)) {
|
||||
let variableSerializer = this.store.serializerFor('variable');
|
||||
let normalized =
|
||||
variableSerializer.normalizeDefaultJobTemplate(template);
|
||||
return this.store.createRecord('variable', normalized);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef Variable
|
||||
* @type {object}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Lookup a job template variable by ID/path.
|
||||
* @param {string} templateID
|
||||
* @returns {Promise<Variable>}
|
||||
*/
|
||||
async getJobTemplate(templateID) {
|
||||
await this.populateDefaultJobTemplates();
|
||||
const defaultJobs = this.store
|
||||
.peekAll('variable')
|
||||
.filter((template) => template.isDefaultJobTemplate);
|
||||
if (defaultJobs.find((job) => job.id === templateID)) {
|
||||
return defaultJobs.find((job) => job.id === templateID);
|
||||
} else {
|
||||
return this.store.findRecord('variable', templateID);
|
||||
}
|
||||
}
|
||||
|
||||
urlForFindAll(modelName) {
|
||||
let baseUrl = this.buildURL(modelName);
|
||||
return pluralize(baseUrl);
|
||||
|
@ -26,9 +91,22 @@ export default class VariableAdapter extends ApplicationAdapter {
|
|||
return pluralize(baseUrl);
|
||||
}
|
||||
|
||||
urlForFindRecord(identifier, modelName, snapshot) {
|
||||
const { namespace, id } = _extractIDAndNamespace(identifier, snapshot);
|
||||
let baseUrl = this.buildURL(modelName, id);
|
||||
urlForFindRecord(identifier, modelName) {
|
||||
let path,
|
||||
namespace = null;
|
||||
|
||||
// TODO: Variables are namespaced. This Adapter should extend the WatchableNamespaceId Adapter.
|
||||
// When that happens, we will need to refactor this to accept JSON tuple like we do for jobs.
|
||||
const delimiter = identifier.lastIndexOf('@');
|
||||
if (delimiter !== -1) {
|
||||
path = identifier.slice(0, delimiter);
|
||||
namespace = identifier.slice(delimiter + 1);
|
||||
} else {
|
||||
path = identifier;
|
||||
namespace = 'default';
|
||||
}
|
||||
|
||||
let baseUrl = this.buildURL(modelName, path);
|
||||
return `${baseUrl}?namespace=${namespace}`;
|
||||
}
|
||||
|
||||
|
@ -50,6 +128,9 @@ export default class VariableAdapter extends ApplicationAdapter {
|
|||
}
|
||||
|
||||
handleResponse(status, _, payload) {
|
||||
if (status === 404) {
|
||||
return new AdapterError([{ detail: payload, status: 404 }]);
|
||||
}
|
||||
if (status === 409) {
|
||||
return new ConflictError([
|
||||
{ detail: _normalizeConflictErrorObject(payload), status: 409 },
|
||||
|
|
|
@ -13,11 +13,13 @@ import classic from 'ember-classic-decorator';
|
|||
export default class JobEditor extends Component {
|
||||
@service store;
|
||||
@service config;
|
||||
@service router;
|
||||
|
||||
'data-test-job-editor' = true;
|
||||
|
||||
job = null;
|
||||
onSubmit() {}
|
||||
handleSaveAsTemplate() {}
|
||||
|
||||
@computed('_context')
|
||||
get context() {
|
||||
|
@ -104,6 +106,7 @@ export default class JobEditor extends Component {
|
|||
})
|
||||
submit;
|
||||
|
||||
@action
|
||||
reset() {
|
||||
this.set('planOutput', null);
|
||||
this.set('planError', null);
|
||||
|
|
|
@ -3,10 +3,12 @@
|
|||
<form class="new-variables" autocomplete="off" {{on "submit" this.save}}>
|
||||
|
||||
{{#if @model.isNew}}
|
||||
<div class="related-entities related-entities-hint">
|
||||
<p>Prefix your path with <code>nomad/jobs/</code> to automatically make your variable accessible to a specified job, task group, or task.<br />
|
||||
Format: <code>nomad/jobs/<jobname></code>, <code>nomad/jobs/<jobname>/<groupname></code>, <code>nomad/jobs/<jobname>/<groupname>/<taskname></code></p>
|
||||
</div>
|
||||
{{#unless this.isJobTemplateVariable}}
|
||||
<div class="related-entities related-entities-hint">
|
||||
<p>Prefix your path with <code>nomad/jobs/</code> to automatically make your variable accessible to a specified job, task group, or task.<br />
|
||||
Format: <code>nomad/jobs/<jobname></code>, <code>nomad/jobs/<jobname>/<groupname></code>, <code>nomad/jobs/<jobname>/<groupname>/<taskname></code></p>
|
||||
</div>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
|
||||
{{#if this.hasConflict}}
|
||||
|
@ -61,6 +63,16 @@
|
|||
.
|
||||
</p>
|
||||
{{/if}}
|
||||
{{#if this.isJobTemplateVariable}}
|
||||
<p class="help job-template-hint">
|
||||
Use this variable to generate job templates with
|
||||
<code>nomad job init -template={{this.jobTemplateName}}
|
||||
<CopyButton
|
||||
@clipboardText="nomad job init -template={{this.jobTemplateName}}"
|
||||
/>
|
||||
</code>
|
||||
</p>
|
||||
{{/if}}
|
||||
</label>
|
||||
<VariableForm::NamespaceFilter
|
||||
@data={{hash
|
||||
|
@ -74,42 +86,48 @@
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
{{#if (eq this.view "json")}}
|
||||
<div
|
||||
class="editor-wrapper boxed-section-body is-full-bleed
|
||||
{{if this.JSONError 'error'}}"
|
||||
>
|
||||
<div
|
||||
data-test-json-editor
|
||||
{{code-mirror
|
||||
content=this.JSONItems
|
||||
onUpdate=this.updateCode
|
||||
extraKeys=(hash Cmd-Enter=(action "save"))
|
||||
}}
|
||||
></div>
|
||||
{{#if this.JSONError}}
|
||||
<p class="help is-danger">
|
||||
{{this.JSONError}}
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{#if this.isJobTemplateVariable}}
|
||||
<VariableForm::JobTemplateEditor
|
||||
@keyValues={{this.keyValues}}
|
||||
@updateKeyValue={{this.updateKeyValue}}
|
||||
/>
|
||||
{{else}}
|
||||
{{#each this.keyValues as |entry iter|}}
|
||||
<div class="key-value">
|
||||
<label>
|
||||
<span>
|
||||
Key
|
||||
</span>
|
||||
<Input
|
||||
data-test-var-key
|
||||
@type="text"
|
||||
@value={{entry.key}}
|
||||
class="input"
|
||||
{{autofocus ignore=(eq iter 0)}}
|
||||
{{on "input" (fn this.validateKey entry)}}
|
||||
/>
|
||||
</label>
|
||||
<VariableForm::InputGroup @entry={{entry}} />
|
||||
{{#if (eq this.view "json")}}
|
||||
<div
|
||||
class="editor-wrapper boxed-section-body is-full-bleed
|
||||
{{if this.JSONError 'error'}}"
|
||||
>
|
||||
<div
|
||||
data-test-json-editor
|
||||
{{code-mirror
|
||||
content=this.JSONItems
|
||||
onUpdate=this.updateCode
|
||||
extraKeys=(hash Cmd-Enter=(action "save"))
|
||||
}}
|
||||
></div>
|
||||
{{#if this.JSONError}}
|
||||
<p class="help is-danger">
|
||||
{{this.JSONError}}
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{else}}
|
||||
{{#each this.keyValues as |entry iter|}}
|
||||
<div class="key-value">
|
||||
<label>
|
||||
<span>
|
||||
Key
|
||||
</span>
|
||||
<Input
|
||||
data-test-var-key
|
||||
@type="text"
|
||||
@value={{entry.key}}
|
||||
class="input"
|
||||
{{autofocus ignore=(eq iter 0)}}
|
||||
{{on "input" (fn this.validateKey entry)}}
|
||||
/>
|
||||
</label>
|
||||
<VariableForm::InputGroup @entry={{entry}} />
|
||||
<button
|
||||
class="delete-row button is-danger is-inverted"
|
||||
type="button"
|
||||
|
@ -117,13 +135,14 @@
|
|||
>
|
||||
Delete
|
||||
</button>
|
||||
{{#each-in entry.warnings as |k v|}}
|
||||
<span class="key-value-error help is-danger">
|
||||
{{v}}
|
||||
</span>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
{{/each}}
|
||||
{{#each-in entry.warnings as |k v|}}
|
||||
<span class="key-value-error help is-danger">
|
||||
{{v}}
|
||||
</span>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if (and this.shouldShowLinkedEntities @model.isNew)}}
|
||||
|
@ -138,15 +157,17 @@
|
|||
|
||||
<footer>
|
||||
{{#unless this.isJSONView}}
|
||||
<button
|
||||
class="add-more button is-info is-inverted"
|
||||
type="button"
|
||||
disabled={{not (and this.keyValues.lastObject.key this.keyValues.lastObject.value)}}
|
||||
{{on "click" this.appendRow}}
|
||||
data-test-add-kv
|
||||
>
|
||||
Add More
|
||||
</button>
|
||||
{{#unless this.isJobTemplateVariable}}
|
||||
<button
|
||||
class="add-more button is-info is-inverted"
|
||||
type="button"
|
||||
disabled={{not (and this.keyValues.lastObject.key this.keyValues.lastObject.value)}}
|
||||
{{on "click" this.appendRow}}
|
||||
data-test-add-kv
|
||||
>
|
||||
Add More
|
||||
</button>
|
||||
{{/unless}}
|
||||
{{/unless}}
|
||||
<button
|
||||
disabled={{this.shouldDisableSave}}
|
||||
|
|
|
@ -64,10 +64,23 @@ export default class VariableFormComponent extends Component {
|
|||
|
||||
get shouldDisableSave() {
|
||||
const disallowedPath =
|
||||
this.path?.startsWith('nomad/') && !this.path?.startsWith('nomad/jobs');
|
||||
this.path?.startsWith('nomad/') &&
|
||||
!(
|
||||
this.path?.startsWith('nomad/jobs') ||
|
||||
(this.path?.startsWith('nomad/job-templates') &&
|
||||
trimPath([this.path]) !== 'nomad/job-templates')
|
||||
);
|
||||
return !!this.JSONError || !this.path || disallowedPath;
|
||||
}
|
||||
|
||||
get isJobTemplateVariable() {
|
||||
return this.path?.startsWith('nomad/job-templates/');
|
||||
}
|
||||
|
||||
get jobTemplateName() {
|
||||
return this.path.split('nomad/job-templates/').slice(-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {MutableArray<{key: string, value: string, warnings: EmberObject}>}
|
||||
*/
|
||||
|
@ -176,6 +189,14 @@ export default class VariableFormComponent extends Component {
|
|||
set(this.args.model, 'path', e.target.value);
|
||||
}
|
||||
|
||||
@action updateKeyValue(key, value) {
|
||||
if (this.keyValues.find((kv) => kv.key === key)) {
|
||||
this.keyValues.find((kv) => kv.key === key).value = value;
|
||||
} else {
|
||||
this.keyValues.pushObject({ key, value, warnings: EmberObject.create() });
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async save(e, overwrite = false) {
|
||||
if (e.type === 'submit') {
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
{{did-insert this.establishKeyValues}}
|
||||
<div>
|
||||
<label>
|
||||
<span>
|
||||
Description
|
||||
</span>
|
||||
<Input
|
||||
@type="text"
|
||||
@value={{this.description}}
|
||||
{{on "input" this.updateDescription}}
|
||||
class="input value-input"
|
||||
data-test-template-description
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<span>
|
||||
Job Template
|
||||
</span>
|
||||
<div
|
||||
data-test-template-json
|
||||
{{code-mirror
|
||||
theme="hashi"
|
||||
mode="ruby"
|
||||
autofocus=false
|
||||
content=this.template
|
||||
onUpdate=(action this.updateTemplate)
|
||||
}}
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
|
@ -0,0 +1,23 @@
|
|||
// @ts-check
|
||||
import { action } from '@ember/object';
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class JobTemplateEditor extends Component {
|
||||
@tracked description;
|
||||
@tracked template;
|
||||
@action
|
||||
establishKeyValues() {
|
||||
this.description = this.args.keyValues.findBy('key', 'description')?.value;
|
||||
this.template = this.args.keyValues.findBy('key', 'template')?.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateDescription(event) {
|
||||
this.args.updateKeyValue('description', event.target.value);
|
||||
}
|
||||
@action
|
||||
updateTemplate(value) {
|
||||
this.args.updateKeyValue('template', value);
|
||||
}
|
||||
}
|
|
@ -1,9 +1,12 @@
|
|||
import Controller from '@ember/controller';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class RunController extends Controller {
|
||||
@service router;
|
||||
onSubmit(id, namespace) {
|
||||
this.router.transitionTo('jobs.job', `${id}@${namespace || 'default'}`);
|
||||
export default class JobsRunController extends Controller {
|
||||
@tracked jsonTemplate = null;
|
||||
|
||||
@action
|
||||
setTemplate(template) {
|
||||
this.jsonTemplate = template;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
import { getOwner } from '@ember/application';
|
||||
import Controller from '@ember/controller';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class RunController extends Controller {
|
||||
@service router;
|
||||
|
||||
queryParams = ['template'];
|
||||
|
||||
@action
|
||||
handleSaveAsTemplate() {
|
||||
getOwner(this)
|
||||
.lookup('controller:jobs.run')
|
||||
.setTemplate(this.model._newDefinition);
|
||||
}
|
||||
|
||||
onSubmit(id, namespace) {
|
||||
this.router.transitionTo('jobs.job', `${id}@${namespace || 'default'}`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import Controller from '@ember/controller';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class JobsRunTemplatesIndexController extends Controller {
|
||||
@tracked selectedTemplate = null;
|
||||
|
||||
get templates() {
|
||||
return [...this.model.variables.toArray(), ...this.model.default];
|
||||
}
|
||||
|
||||
@action
|
||||
onChange(e) {
|
||||
this.selectedTemplate = e.target.id;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import Controller from '@ember/controller';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
export default class JobsRunTemplatesManageController extends Controller {
|
||||
@service flashMessages;
|
||||
@service router;
|
||||
|
||||
get templates() {
|
||||
return [...this.model.variables.toArray(), ...this.model.default];
|
||||
}
|
||||
|
||||
@tracked selectedTemplate = null;
|
||||
|
||||
columns = ['name', 'namespace', 'description', 'delete'].map((column) => {
|
||||
return {
|
||||
key: column,
|
||||
label: `${column.charAt(0).toUpperCase()}${column.substring(1)}`,
|
||||
};
|
||||
});
|
||||
|
||||
formatTemplateLabel(path) {
|
||||
return path.split('nomad/job-templates/')[1];
|
||||
}
|
||||
|
||||
@task(function* (model) {
|
||||
try {
|
||||
yield model.destroyRecord();
|
||||
this.flashMessages.add({
|
||||
title: 'Job template deleted',
|
||||
message: `${model.path} successfully deleted`,
|
||||
type: 'success',
|
||||
destroyOnClick: false,
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
this.flashMessages.add({
|
||||
title: `Job template could not be deleted.`,
|
||||
message: err,
|
||||
type: 'error',
|
||||
destroyOnClick: false,
|
||||
sticky: true,
|
||||
});
|
||||
}
|
||||
})
|
||||
deleteTemplate;
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import Controller from '@ember/controller';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { trimPath } from '../../../../helpers/trim-path';
|
||||
|
||||
export default class JobsRunTemplatesNewController extends Controller {
|
||||
@service router;
|
||||
@service store;
|
||||
@service system;
|
||||
@tracked templateName = null;
|
||||
@tracked templateNamespace = 'default';
|
||||
|
||||
get namespaceOptions() {
|
||||
const namespaces = this.store
|
||||
.peekAll('namespace')
|
||||
.map(({ name }) => ({ key: name, label: name }));
|
||||
|
||||
return namespaces;
|
||||
}
|
||||
|
||||
get isDuplicateTemplate() {
|
||||
const templates = this.store.peekAll('variable');
|
||||
const templateName = trimPath([`nomad/job-templates/${this.templateName}`]);
|
||||
|
||||
return !!templates
|
||||
.without(this.model)
|
||||
.find(
|
||||
(v) => v.path === templateName && v.namespace === this.templateNamespace
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
updateKeyValue(key, value) {
|
||||
if (this.model.keyValues.find((kv) => kv.key === key)) {
|
||||
this.model.keyValues.find((kv) => kv.key === key).value = value;
|
||||
} else {
|
||||
this.model.keyValues.pushObject({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async save(e, overwrite = false) {
|
||||
if (e.type === 'submit') {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (this.model?.isNew) {
|
||||
if (this.namespaceOptions) {
|
||||
this.model.set('namespace', this.templateNamespace);
|
||||
} else {
|
||||
const [namespace] = this.store.peekAll('namespace').toArray();
|
||||
this.model.set('namespace', namespace.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.model.set('path', `nomad/job-templates/${this.templateName}`);
|
||||
this.model.setAndTrimPath();
|
||||
|
||||
try {
|
||||
await this.model.save({ adapterOptions: { overwrite } });
|
||||
|
||||
this.flashMessages.add({
|
||||
title: 'Job template saved',
|
||||
message: `${this.templateName} successfully saved`,
|
||||
type: 'success',
|
||||
destroyOnClick: false,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
this.router.transitionTo('jobs.run.templates');
|
||||
} catch (e) {
|
||||
this.flashMessages.add({
|
||||
title: 'Job template cannot be saved.',
|
||||
message: e,
|
||||
type: 'error',
|
||||
destroyOnClick: false,
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import Controller from '@ember/controller';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { task } from 'ember-concurrency';
|
||||
|
||||
export default class JobsRunTemplatesController extends Controller {
|
||||
@service flashMessages;
|
||||
@service router;
|
||||
@service system;
|
||||
|
||||
@tracked formModalActive = false;
|
||||
|
||||
@action
|
||||
updateKeyValue(key, value) {
|
||||
if (this.model.keyValues.find((kv) => kv.key === key)) {
|
||||
this.model.keyValues.find((kv) => kv.key === key).value = value;
|
||||
} else {
|
||||
this.model.keyValues.pushObject({ key, value });
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
toggleModal() {
|
||||
this.formModalActive = !this.formModalActive;
|
||||
}
|
||||
|
||||
@action
|
||||
async save(e, overwrite = false) {
|
||||
if (e.type === 'submit') {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
try {
|
||||
await this.model.save({ adapterOptions: { overwrite } });
|
||||
|
||||
this.flashMessages.add({
|
||||
title: 'Job template saved',
|
||||
message: `${this.model.path} successfully editted`,
|
||||
type: 'success',
|
||||
destroyOnClick: false,
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
this.router.transitionTo('jobs.run.templates');
|
||||
} catch (e) {
|
||||
this.flashMessages.add({
|
||||
title: 'Job template cannot be editted.',
|
||||
message: e,
|
||||
type: 'error',
|
||||
destroyOnClick: false,
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@task(function* () {
|
||||
try {
|
||||
yield this.model.destroyRecord();
|
||||
|
||||
this.flashMessages.add({
|
||||
title: 'Job template deleted',
|
||||
message: `${this.model.path} successfully deleted`,
|
||||
type: 'success',
|
||||
destroyOnClick: false,
|
||||
timeout: 5000,
|
||||
});
|
||||
this.router.transitionTo('jobs.run.templates.manage');
|
||||
} catch (err) {
|
||||
this.flashMessages.add({
|
||||
title: `Job template could not be deleted.`,
|
||||
message: err,
|
||||
type: 'error',
|
||||
destroyOnClick: false,
|
||||
sticky: true,
|
||||
});
|
||||
}
|
||||
})
|
||||
deleteTemplate;
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { helper } from '@ember/component/helper';
|
||||
import { capitalize } from '@ember/string';
|
||||
|
||||
export default helper(function formatTemplateLabel([path]) {
|
||||
// Removes the preceeding nomad/job-templates/default/
|
||||
let label;
|
||||
const delimiter = path.lastIndexOf('/');
|
||||
if (delimiter !== -1) {
|
||||
label = path.slice(delimiter + 1);
|
||||
} else {
|
||||
label = path;
|
||||
}
|
||||
return capitalize(label).split('-').join(' ');
|
||||
});
|
|
@ -0,0 +1,8 @@
|
|||
import { helper } from '@ember/component/helper';
|
||||
|
||||
function invokeFn([scope, fn]) {
|
||||
let args = arguments[0].slice(2);
|
||||
return fn.apply(scope, args);
|
||||
}
|
||||
|
||||
export default helper(invokeFn);
|
|
@ -14,7 +14,13 @@ Router.map(function () {
|
|||
});
|
||||
|
||||
this.route('jobs', function () {
|
||||
this.route('run');
|
||||
this.route('run', function () {
|
||||
this.route('templates', function () {
|
||||
this.route('new');
|
||||
this.route('manage');
|
||||
this.route('template', { path: '/:name' });
|
||||
});
|
||||
});
|
||||
this.route('job', { path: '/:job_name' }, function () {
|
||||
this.route('task-group', { path: '/:name' });
|
||||
this.route('definition');
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import classic from 'ember-classic-decorator';
|
||||
|
||||
@classic
|
||||
export default class RunRoute extends Route {
|
||||
@service can;
|
||||
@service store;
|
||||
@service system;
|
||||
|
||||
beforeModel(transition) {
|
||||
if (
|
||||
this.can.cannot('run job', null, {
|
||||
namespace: transition.to.queryParams.namespace,
|
||||
})
|
||||
) {
|
||||
this.transitionTo('jobs');
|
||||
}
|
||||
}
|
||||
|
||||
model() {
|
||||
// When jobs are created with a namespace attribute, it is verified against
|
||||
// available namespaces to prevent redirecting to a non-existent namespace.
|
||||
return this.store.findAll('namespace').then(() => {
|
||||
return this.store.createRecord('job');
|
||||
});
|
||||
}
|
||||
|
||||
resetController(controller, isExiting) {
|
||||
if (isExiting) {
|
||||
controller.model.deleteRecord();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
// @ts-check
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import classic from 'ember-classic-decorator';
|
||||
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
|
||||
|
||||
@classic
|
||||
export default class JobsRunIndexRoute extends Route {
|
||||
@service can;
|
||||
@service flashMessages;
|
||||
@service router;
|
||||
@service store;
|
||||
@service system;
|
||||
|
||||
queryParams = {
|
||||
template: {
|
||||
refreshModel: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeModel(transition) {
|
||||
if (
|
||||
this.can.cannot('run job', null, {
|
||||
namespace: transition.to.queryParams.namespace,
|
||||
})
|
||||
) {
|
||||
this.router.transitionTo('jobs');
|
||||
}
|
||||
}
|
||||
|
||||
async model({ template }) {
|
||||
try {
|
||||
// When jobs are created with a namespace attribute, it is verified against
|
||||
// available namespaces to prevent redirecting to a non-existent namespace.
|
||||
await this.store.findAll('namespace');
|
||||
|
||||
if (template) {
|
||||
const VariableAdapter = this.store.adapterFor('variable');
|
||||
const templateRecord = await VariableAdapter.getJobTemplate(template);
|
||||
return this.store.createRecord('job', {
|
||||
_newDefinition: templateRecord.items.template,
|
||||
});
|
||||
} else {
|
||||
return this.store.createRecord('job');
|
||||
}
|
||||
} catch (e) {
|
||||
this.handle404(e);
|
||||
}
|
||||
}
|
||||
|
||||
handle404(e) {
|
||||
const error404 = e.errors?.find((err) => err.status === 404);
|
||||
if (error404) {
|
||||
this.flashMessages.add({
|
||||
title: `Error loading job template`,
|
||||
message: error404.detail,
|
||||
type: 'error',
|
||||
destroyOnClick: false,
|
||||
sticky: true,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
notifyForbidden(this)(e);
|
||||
}
|
||||
|
||||
resetController(controller, isExiting) {
|
||||
if (isExiting) {
|
||||
controller.model?.deleteRecord();
|
||||
controller.set('template', null);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
// @ts-check
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class JobsRunTemplatesIndexRoute extends Route {
|
||||
@service can;
|
||||
@service router;
|
||||
@service store;
|
||||
|
||||
beforeModel() {
|
||||
const hasPermissions = this.can.can('write variable', null, {
|
||||
namespace: '*',
|
||||
path: '*',
|
||||
});
|
||||
|
||||
if (!hasPermissions) {
|
||||
this.router.transitionTo('jobs');
|
||||
}
|
||||
}
|
||||
|
||||
model() {
|
||||
return this.store.adapterFor('variable').getJobTemplates();
|
||||
}
|
||||
|
||||
resetController(controller) {
|
||||
controller.set('selectedTemplate', null);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
|
||||
export default class JobsRunTemplatesManageRoute extends Route {
|
||||
@service can;
|
||||
@service router;
|
||||
@service store;
|
||||
|
||||
beforeModel() {
|
||||
const hasPermissions = this.can.can('write variable', null, {
|
||||
namespace: '*',
|
||||
path: '*',
|
||||
});
|
||||
|
||||
if (!hasPermissions) {
|
||||
this.router.transitionTo('jobs');
|
||||
}
|
||||
}
|
||||
|
||||
model() {
|
||||
return this.store.adapterFor('variable').getJobTemplates();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { getOwner } from '@ember/application';
|
||||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
|
||||
|
||||
export default class JobsRunTemplatesNewRoute extends Route {
|
||||
@service can;
|
||||
@service router;
|
||||
@service store;
|
||||
@service system;
|
||||
|
||||
beforeModel(transition) {
|
||||
if (
|
||||
this.can.cannot('write variable', null, {
|
||||
namespace: transition.to.queryParams.namespace,
|
||||
})
|
||||
) {
|
||||
this.router.transitionTo('jobs.run');
|
||||
}
|
||||
}
|
||||
|
||||
async model() {
|
||||
try {
|
||||
// When variables are created with a namespace attribute, it is verified against
|
||||
// available namespaces to prevent redirecting to a non-existent namespace.
|
||||
await Promise.all([
|
||||
this.store.query('variable', {
|
||||
prefix: 'nomad/job-templates',
|
||||
namespace: '*',
|
||||
}),
|
||||
this.store.findAll('namespace'),
|
||||
]);
|
||||
|
||||
// When navigating from jobs.run.index using "Save as Template"
|
||||
const json = getOwner(this).lookup('controller:jobs.run').jsonTemplate;
|
||||
if (json) {
|
||||
return this.store.createRecord('variable', {
|
||||
keyValues: [{ key: 'template', value: json }],
|
||||
});
|
||||
}
|
||||
|
||||
// Override Default Value
|
||||
return this.store.createRecord('variable', { keyValues: [] });
|
||||
} catch (e) {
|
||||
notifyForbidden(this)(e);
|
||||
}
|
||||
}
|
||||
|
||||
resetController(controller, isExiting) {
|
||||
if (
|
||||
isExiting &&
|
||||
controller.model.isNew &&
|
||||
!controller.model.isDestroyed &&
|
||||
!controller.model.isDestroying
|
||||
) {
|
||||
controller.model?.unloadRecord();
|
||||
}
|
||||
controller.set('templateName', null);
|
||||
controller.set('templateNamespace', 'default');
|
||||
getOwner(this).lookup('controller:jobs.run').jsonTemplate = null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import Route from '@ember/routing/route';
|
||||
import { inject as service } from '@ember/service';
|
||||
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
|
||||
|
||||
export default class JobsRunTemplatesTemplateRoute extends Route {
|
||||
@service can;
|
||||
@service router;
|
||||
@service store;
|
||||
@service system;
|
||||
|
||||
beforeModel(transition) {
|
||||
if (
|
||||
this.can.cannot('write variable', null, {
|
||||
namespace: transition.to.queryParams.namespace,
|
||||
})
|
||||
) {
|
||||
this.router.transitionTo('jobs.run');
|
||||
}
|
||||
}
|
||||
|
||||
async model({ name }) {
|
||||
try {
|
||||
return this.store.findRecord('variable', name);
|
||||
} catch (e) {
|
||||
notifyForbidden(this)(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -32,6 +32,14 @@ export default class VariableSerializer extends ApplicationSerializer {
|
|||
);
|
||||
}
|
||||
|
||||
normalizeDefaultJobTemplate(hash) {
|
||||
return {
|
||||
path: hash.id,
|
||||
isDefaultJobTemplate: true,
|
||||
...hash,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform our KeyValues array into an Items object
|
||||
serialize(snapshot, options) {
|
||||
const json = super.serialize(snapshot, options);
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
@import 'ember-power-select';
|
||||
|
||||
@import './components';
|
||||
@import '@hashicorp/design-system-components';
|
||||
@import './charts';
|
||||
|
||||
// Only necessary in dev
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
@import './components/accordion';
|
||||
@import './components/badge';
|
||||
@import './components/badge-nomad-internal';
|
||||
@import './components/boxed-section';
|
||||
@import './components/codemirror';
|
||||
@import './components/copy-button';
|
||||
@import './components/cli-window';
|
||||
@import './components/das-interstitial';
|
||||
@import './components/dashboard-metric';
|
||||
@import './components/dropdown';
|
||||
@import './components/dropdown-nomad-internal';
|
||||
@import './components/ember-power-select';
|
||||
@import './components/empty-message';
|
||||
@import './components/error-container';
|
||||
|
|
|
@ -138,16 +138,21 @@ header.run-job-header {
|
|||
& > h1 {
|
||||
grid-column: -1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.job-spec-upload {
|
||||
.button {
|
||||
cursor: pointer;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
.job-spec-upload {
|
||||
.button {
|
||||
cursor: pointer;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonset {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
|
|
@ -169,6 +169,25 @@ table.path-tree {
|
|||
}
|
||||
}
|
||||
|
||||
.job-template-hint {
|
||||
margin-top: 0.5rem;
|
||||
code {
|
||||
background-color: #eee;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.copy-button {
|
||||
display: inline-block;
|
||||
padding-left: 0;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
button,
|
||||
.button {
|
||||
background-color: transparent;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.variable-items {
|
||||
// table-layout: fixed;
|
||||
td.value-cell {
|
||||
|
|
|
@ -89,6 +89,42 @@
|
|||
}
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
padding: 16px 0px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
margin: 16px 0px;
|
||||
}
|
||||
|
||||
.align-right {
|
||||
margin-right: calc(6.5% - 16px);
|
||||
}
|
||||
|
||||
.new-job-template {
|
||||
& > div {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.path-input {
|
||||
height: 2.25em;
|
||||
&.error {
|
||||
color: $red;
|
||||
border-color: $red;
|
||||
}
|
||||
|
||||
+ p {
|
||||
position: relative;
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.input-dropdown-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr 1fr;
|
||||
gap: 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mock-sso-provider {
|
||||
margin: 25vh auto;
|
||||
|
@ -119,4 +155,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,11 +21,9 @@
|
|||
|
||||
<header class="run-job-header">
|
||||
<h1 class="title is-3">Run a job</h1>
|
||||
<p>Paste or author HCL or JSON to submit to your cluster. A plan will be requested before the job is submitted. You can also attach a job spec by uploading a job file or dragging & dropping a file to the editor.</p>
|
||||
<label class="job-spec-upload">
|
||||
<input type="file" onchange={{action this.uploadJobSpec}} accept=".hcl,.json,.nomad" />
|
||||
<span class="button">Upload a job spec file</span>
|
||||
</label>
|
||||
<p>
|
||||
Paste or author HCL or JSON to submit to your cluster, or select from a list of templates. A plan will be requested before the job is submitted. You can also attach a job spec by uploading a job file or dragging & dropping a file to the editor.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="boxed-section">
|
||||
|
@ -54,14 +52,23 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content is-associative">
|
||||
<button
|
||||
class="button is-primary {{if this.plan.isRunning 'is-loading'}}"
|
||||
type="button"
|
||||
onclick={{perform this.plan}}
|
||||
<div class="is-associative buttonset">
|
||||
<Hds::Button
|
||||
{{on "click" (perform this.plan)}}
|
||||
disabled={{or this.plan.isRunning (not this.job._newDefinition)}}
|
||||
data-test-plan
|
||||
>Plan</button>
|
||||
@text="Plan"
|
||||
/>
|
||||
<Hds::ButtonSet>
|
||||
<label class="job-spec-upload hds-button hds-button--color-secondary hds-button--size-medium">
|
||||
<div class="hds-button__text">Upload file</div>
|
||||
<input type="file" onchange={{action this.uploadJobSpec}} accept=".hcl,.json,.nomad" />
|
||||
</label>
|
||||
{{#if (can "write variable" path="*" namespace="*")}}
|
||||
<Hds::Button @icon="file-plus" @text="Save as template" @color="tertiary" @route="jobs.run.templates.new" {{on "click" this.handleSaveAsTemplate}} data-test-save-as-template />
|
||||
<Hds::Button @icon="file-text" @text="Choose from a template" @color="tertiary" @route="jobs.run.templates" data-test-choose-template />
|
||||
{{/if}}
|
||||
</Hds::ButtonSet>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
|
@ -75,12 +82,7 @@
|
|||
will have on your cluster.</p>
|
||||
</div>
|
||||
<div class="column is-centered is-minimum">
|
||||
<button
|
||||
class="button is-info"
|
||||
onclick={{toggle-action "showPlanMessage" this}}
|
||||
data-test-plan-help-dismiss
|
||||
type="button"
|
||||
>Okay</button>
|
||||
<Hds::Button @text="Okay" {{on "click" (toggle-action "showPlanMessage" this)}} data-test-plan-help-dismiss />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -147,19 +149,8 @@
|
|||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="content is-associative">
|
||||
<button
|
||||
class="button is-primary {{if this.submit.isRunning 'is-loading'}}"
|
||||
type="button"
|
||||
onclick={{perform this.submit}}
|
||||
disabled={{this.submit.isRunning}}
|
||||
data-test-run
|
||||
>Run</button>
|
||||
<button
|
||||
class="button is-light"
|
||||
type="button"
|
||||
onclick={{action this.reset}}
|
||||
data-test-cancel
|
||||
>Cancel</button>
|
||||
</div>
|
||||
<Hds::ButtonSet class="is-associative">
|
||||
<Hds::Button @size="small" @text="Run" {{on "click" (perform this.submit)}} disabled={{this.submit.isRunning}} data-test-run />
|
||||
<Hds::Button @size="small" @color="critical" @text="Cancel" {{on "click" this.reset}} disabled={{this.submit.isRunning}} data-test-cancel />
|
||||
</Hds::ButtonSet>
|
||||
{{/if}}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<Breadcrumb @crumb={{hash label="Run" args=(array "jobs.run")}} />
|
||||
{{page-title "Run a job"}}
|
||||
<section class="section">
|
||||
<JobEditor @job={{this.model}} @context="new" @onSubmit={{action this.onSubmit}} />
|
||||
<JobEditor @job={{this.model}} @context="new" @onSubmit={{action this.onSubmit}} @handleSaveAsTemplate={{this.handleSaveAsTemplate}} />
|
||||
</section>
|
|
@ -0,0 +1,34 @@
|
|||
<section class="section">
|
||||
<header class="run-job-header">
|
||||
<h1 class="title is-3">Choose a template</h1>
|
||||
<p>Select a custom or default job template below. You will have an opportunity to modify and plan your job before it is submitted.</p>
|
||||
</header>
|
||||
{{#if (eq this.templates.length 0)}}
|
||||
<h3 data-test-empty-templates-list-headline class="empty-message-headline">
|
||||
You have no templates to choose from. Would you like to <Hds::Link::Inline @route="jobs.run.templates.new" data-test-create-inline>create</Hds::Link::Inline> one?
|
||||
</h3>
|
||||
<Hds::Button class="button-group" @text="Back to editor" @route="jobs.run" @icon="arrow-left" data-test-cancel />
|
||||
{{else}}
|
||||
<main class="radio-group" data-test-template-list>
|
||||
<Hds::Form::RadioCard::Group as |G|>
|
||||
<G.Legend>Select a Template</G.Legend>
|
||||
{{#each this.templates as |card|}}
|
||||
<G.RadioCard class="form-container" @layout="fixed" @maxWidth="30%" @checked={{eq card.id this.selectedTemplate}} id={{card.id}} data-test-template-card={{format-template-label card.path}} {{on "change" this.onChange}} as |R|>
|
||||
<R.Label data-test-template-label>{{format-template-label card.path}}</R.Label>
|
||||
<R.Description data-test-template-description>{{card.items.description}}</R.Description>
|
||||
</G.RadioCard>
|
||||
{{/each}}
|
||||
</Hds::Form::RadioCard::Group>
|
||||
</main>
|
||||
<footer class="buttonset">
|
||||
<Hds::ButtonSet class="button-group">
|
||||
<Hds::Button @text="Apply" @route="jobs.run" @query={{hash template=this.selectedTemplate}} disabled={{is-equal this.selectedTemplate null}} data-test-apply />
|
||||
<Hds::Button @text="Cancel" @route="jobs.run" @color="secondary" data-test-cancel />
|
||||
</Hds::ButtonSet>
|
||||
<Hds::ButtonSet class="button-group align-right">
|
||||
<Hds::Button @text="Manage" @color="tertiary" @icon="edit" @route="jobs.run.templates.manage" data-test-manage-button />
|
||||
<Hds::Button @text="Create New Template" @color="tertiary" @icon="file-plus" @route="jobs.run.templates.new" data-test-create-new-button />
|
||||
</Hds::ButtonSet>
|
||||
</footer>
|
||||
{{/if}}
|
||||
</section>
|
|
@ -0,0 +1,57 @@
|
|||
{{page-title "Manage templates"}}
|
||||
<section class="section">
|
||||
<header class="run-job-header">
|
||||
<h1 class="title is-3">Manage Job Templates</h1>
|
||||
<p>Modify or Delete a job template from the list below. Default job templates cannot be removed.</p>
|
||||
</header>
|
||||
{{#if (eq this.model.length 0)}}
|
||||
<h3 data-test-empty-templates-list-headline class="empty-message-headline">
|
||||
You have no templates to choose from. Would you like to <Hds::Link::Inline @route="jobs.run.templates.new" data-test-create-inline>create</Hds::Link::Inline> one?
|
||||
</h3>
|
||||
<Hds::Button class="button-group" @text="Back to editor" @route="jobs.run" @icon="arrow-left" data-test-cancel />
|
||||
{{else}}
|
||||
<main class="radio-group" data-test-template-list>
|
||||
<Hds::Table @model={{this.templates}} @columns={{this.columns}} @isFixedLayout={{true}}>
|
||||
<:body as |B|>
|
||||
<B.Tr>
|
||||
<B.Td>
|
||||
{{#if B.data.isDefaultJobTemplate}}
|
||||
{{format-template-label B.data.path}}
|
||||
{{else}}
|
||||
<LinkTo @route="jobs.run.templates.template" @model={{concat B.data.path "@" B.data.namespace}} data-test-edit-template={{B.data.path}}>
|
||||
{{format-template-label B.data.path}}
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
</B.Td>
|
||||
<B.Td>{{B.data.namespace}}</B.Td>
|
||||
<B.Td>
|
||||
{{B.data.items.description}}
|
||||
</B.Td>
|
||||
<B.Td>
|
||||
{{#if B.data.isDefaultJobTemplate}}
|
||||
<em>(Default Job — cannot be deleted)</em>
|
||||
{{else}}
|
||||
<TwoStepButton
|
||||
data-test-delete
|
||||
@idleText="Delete"
|
||||
@cancelText="Cancel"
|
||||
@confirmText="Yes"
|
||||
@inlineText={{true}}
|
||||
@confirmationMessage="Are you sure?"
|
||||
@awaitingConfirmation={{this.deleteTemplate.isRunning}}
|
||||
@disabled={{cannot "destroy variable" namespace="*"}}
|
||||
@onConfirm={{perform this.deleteTemplate B.data}}
|
||||
/>
|
||||
{{/if}}
|
||||
</B.Td>
|
||||
</B.Tr>
|
||||
</:body>
|
||||
</Hds::Table>
|
||||
</main>
|
||||
<footer class="buttonset">
|
||||
<Hds::ButtonSet class="button-group">
|
||||
<Hds::Button @text="Cancel" @color="secondary" @route="jobs.run.templates" data-test-done />
|
||||
</Hds::ButtonSet>
|
||||
</footer>
|
||||
{{/if}}
|
||||
</section>
|
|
@ -0,0 +1,51 @@
|
|||
{{page-title "Create a custom template"}}
|
||||
<section class="section">
|
||||
<header class="run-job-header">
|
||||
<h1 class="title is-3">Create template</h1>
|
||||
<p>Provide a job spec that you or others can re-use later. Anytime it is applied to a new job, you will have the opportunity to modify it before that job is run.</p>
|
||||
</header>
|
||||
<form class="new-job-template" autocomplete="off">
|
||||
<div class={{if this.system.shouldShowNamespaces "input-dropdown-row"}}>
|
||||
<label>
|
||||
<span>
|
||||
Template name
|
||||
</span>
|
||||
<Input
|
||||
@type="text"
|
||||
@value={{mut this.templateName}}
|
||||
placeholder="your-template-name-here"
|
||||
class="input path-input {{if this.isDuplicateTemplate "error"}}"
|
||||
{{autofocus}}
|
||||
data-test-template-name
|
||||
/>
|
||||
{{#if this.isDuplicateTemplate}}
|
||||
<p class="help is-danger" data-test-duplicate-error>
|
||||
There is already a templated named {{this.templateName}}.
|
||||
</p>
|
||||
{{/if}}
|
||||
</label>
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<label>
|
||||
<span>
|
||||
Namespace
|
||||
</span>
|
||||
<SingleSelectDropdown
|
||||
data-test-namespace-facet
|
||||
@label="Namespace"
|
||||
@options={{this.namespaceOptions}}
|
||||
@selection={{this.templateNamespace}}
|
||||
@onSelect={{fn (mut this.templateNamespace)}}
|
||||
/>
|
||||
</label>
|
||||
{{/if}}
|
||||
</div>
|
||||
<VariableForm::JobTemplateEditor
|
||||
@keyValues={{this.model.keyValues}}
|
||||
@updateKeyValue={{this.updateKeyValue}}
|
||||
/>
|
||||
<footer class="button-group">
|
||||
<Hds::Button @text="Save" disabled={{is-empty this.templateName}} {{on "click" this.save}} data-test-save-template />
|
||||
<Hds::Button @text="Cancel" @route="jobs.run" @color="secondary" data-test-cancel-template />
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
|
@ -0,0 +1,84 @@
|
|||
{{page-title "Edit template"}}
|
||||
<section class="section">
|
||||
<header class="run-job-header">
|
||||
<div>
|
||||
<h1 class="title is-3">Edit template</h1>
|
||||
</div>
|
||||
<TwoStepButton
|
||||
data-test-delete
|
||||
@alignRight={{true}}
|
||||
@idleText="Delete"
|
||||
@cancelText="Cancel"
|
||||
@confirmText="Yes, Delete Template"
|
||||
@inlineText={{true}}
|
||||
@confirmationMessage="Are you sure?"
|
||||
@awaitingConfirmation={{this.deleteTemplate.isRunning}}
|
||||
@disabled={{cannot "destroy variable" namespace="*"}}
|
||||
@onConfirm={{perform this.deleteTemplate}}
|
||||
/>
|
||||
</header>
|
||||
<form class="new-job-template" autocomplete="off">
|
||||
<div class={{if this.system.shouldShowNamespaces "input-dropdown-row"}}>
|
||||
<label>
|
||||
<span>
|
||||
Template name
|
||||
</span>
|
||||
<Input
|
||||
@type="text"
|
||||
@value={{this.model.path}}
|
||||
class="input path-input"
|
||||
disabled
|
||||
data-test-template-name
|
||||
/>
|
||||
</label>
|
||||
{{#if this.system.shouldShowNamespaces}}
|
||||
<label>
|
||||
<span>
|
||||
Namespace
|
||||
</span>
|
||||
<SingleSelectDropdown
|
||||
data-test-namespace-facet
|
||||
@label="Namespace"
|
||||
@selection={{this.model.namespace}}
|
||||
{{!-- This is a read-only. But, we must follow the Component API for options --}}
|
||||
@options={{array (hash key=this.model.namespace label=this.model.namespace)}}
|
||||
@disabled={{true}}
|
||||
/>
|
||||
</label>
|
||||
{{/if}}
|
||||
</div>
|
||||
<VariableForm::JobTemplateEditor
|
||||
@keyValues={{this.model.keyValues}}
|
||||
@updateKeyValue={{this.updateKeyValue}}
|
||||
/>
|
||||
<footer class="button-group">
|
||||
<Hds::Button @text="Edit" {{on "click" this.save}} data-test-edit-template />
|
||||
<Hds::Button @text="Cancel" @route="jobs.run.templates" @color="critical" data-test-cancel-template />
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
{{#if this.formModalActive}}
|
||||
<Hds::Modal
|
||||
id="form-modal"
|
||||
@onClose={{this.toggleModal}}
|
||||
@color="critical"
|
||||
data-test-confirmation-modal
|
||||
as |M|
|
||||
>
|
||||
<M.Header>
|
||||
Confirm
|
||||
</M.Header>
|
||||
<M.Body>
|
||||
Are you sure you want to delete this template?
|
||||
</M.Body>
|
||||
<M.Footer as |F|>
|
||||
<Hds::ButtonSet>
|
||||
<Hds::Button type="submit" @text="Confirm"
|
||||
{{on "click" this.deleteTemplateAndClose}}
|
||||
data-test-delete-template
|
||||
/>
|
||||
<Hds::Button type="button" @text="Cancel" @color="secondary" {{on "click" F.close}} />
|
||||
</Hds::ButtonSet>
|
||||
</M.Footer>
|
||||
</Hds::Modal>
|
||||
{{/if}}
|
|
@ -0,0 +1,62 @@
|
|||
import helloWorld from './default_jobs/hello-world';
|
||||
import parameterized from './default_jobs/parameterized';
|
||||
import serviceDiscovery from './default_jobs/service-discovery';
|
||||
import variables from './default_jobs/variables';
|
||||
|
||||
export default [
|
||||
{
|
||||
id: 'nomad/job-templates/default/hello-world',
|
||||
keyValues: [
|
||||
{
|
||||
key: 'template',
|
||||
value: helloWorld,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
value: 'A simple job that runs a single task on a single node',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nomad/job-templates/default/parameterized-job',
|
||||
keyValues: [
|
||||
{
|
||||
key: 'template',
|
||||
value: parameterized,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
value:
|
||||
'A job that can be dispatched multiple times with different payloads and meta values',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nomad/job-templates/default/service-discovery',
|
||||
keyValues: [
|
||||
{
|
||||
key: 'template',
|
||||
value: serviceDiscovery,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
value:
|
||||
'Registers a service in one group, and discovers it in another. Provides a recurring check to ensure the service is healthy',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nomad/job-templates/default/variables',
|
||||
keyValues: [
|
||||
{
|
||||
key: 'template',
|
||||
value: variables,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
value:
|
||||
'Use Nomad Variables to configure the output of a simple HTML page',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
|
@ -0,0 +1,61 @@
|
|||
export default `job "hello-world" {
|
||||
# Specifies the datacenters within which this job should be run.
|
||||
# Leave as "dc1" for the default datacenter.
|
||||
datacenters = ["dc1"]
|
||||
|
||||
meta {
|
||||
# User-defined key/value pairs that can be used in your jobs.
|
||||
# You can also use this meta block within Group and Task levels.
|
||||
foo = "bar"
|
||||
}
|
||||
|
||||
# A group defines a series of tasks that should be co-located
|
||||
# on the same client (host). All tasks within a group will be
|
||||
# placed on the same host.
|
||||
group "servers" {
|
||||
|
||||
network {
|
||||
port "www" {
|
||||
to = 8001
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
provider = "nomad"
|
||||
port = "www"
|
||||
}
|
||||
|
||||
# Tasks are individual units of work that are run by Nomad.
|
||||
task "web" {
|
||||
# This particular task starts a simple web server within a Docker container
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "busybox:1"
|
||||
command = "httpd"
|
||||
args = ["-v", "-f", "-p", "\${NOMAD_PORT_www}", "-h", "/local"]
|
||||
ports = ["www"]
|
||||
}
|
||||
|
||||
template {
|
||||
data = <<EOF
|
||||
<h1>Hello, Nomad!</h1>
|
||||
<ul>
|
||||
<li>Task: {{env "NOMAD_TASK_NAME"}}</li>
|
||||
<li>Group: {{env "NOMAD_GROUP_NAME"}}</li>
|
||||
<li>Job: {{env "NOMAD_JOB_NAME"}}</li>
|
||||
<li>Metadata value for foo: {{env "NOMAD_META_foo"}}</li>
|
||||
<li>Currently running on port: {{env "NOMAD_PORT_www"}}</li>
|
||||
</ul>
|
||||
EOF
|
||||
destination = "local/index.html"
|
||||
}
|
||||
|
||||
# Specify the maximum resources required to run the task
|
||||
resources {
|
||||
cpu = 50
|
||||
memory = 64
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
|
@ -0,0 +1,41 @@
|
|||
export default `job "parameterized-job" {
|
||||
datacenters = ["dc1"]
|
||||
# Unlike service jobs, Batch jobs are intended to run until they exit successfully.
|
||||
type = "batch"
|
||||
|
||||
# Run the job only on Linux or MacOS.
|
||||
constraint {
|
||||
attribute = "\${attr.kernel.name}"
|
||||
operator = "set_contains_any"
|
||||
value = "darwin,linux"
|
||||
}
|
||||
|
||||
# Allow the job to be parameterized, and allow any meta key with
|
||||
# a name starting with "i" to be specified.
|
||||
parameterized {
|
||||
meta_optional = ["MY_META_KEY"]
|
||||
}
|
||||
|
||||
group "group" {
|
||||
task "task" {
|
||||
driver = "docker"
|
||||
config {
|
||||
image = "busybox:1"
|
||||
command = "/bin/sh"
|
||||
args = ["-c", "cat local/template.out", "local/payload.txt"]
|
||||
}
|
||||
|
||||
dispatch_payload {
|
||||
file = "payload.txt"
|
||||
}
|
||||
|
||||
template {
|
||||
data = <<EOH
|
||||
MY_META_KEY: {{env "NOMAD_META_MY_META_KEY"}}
|
||||
EOH
|
||||
|
||||
destination = "local/template.out"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
|
@ -0,0 +1,80 @@
|
|||
export default `job "service-discovery-example" {
|
||||
datacenters = ["dc1"]
|
||||
|
||||
group "client" {
|
||||
task "curl" {
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "curlimages/curl:7.87.0"
|
||||
command = "/bin/ash"
|
||||
args = ["local/script.sh"]
|
||||
}
|
||||
|
||||
template {
|
||||
data = <<EOF
|
||||
#!/usr/bin/env ash
|
||||
|
||||
while true; do
|
||||
{{range nomadService "nomad-service-discovery-example-server"}}
|
||||
curl -L -v http://{{.Address}}:{{.Port}}/
|
||||
{{end}}
|
||||
sleep 3
|
||||
done
|
||||
EOF
|
||||
destination = "local/script.sh"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 10
|
||||
memory = 50
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group "server" {
|
||||
network {
|
||||
port "www" {
|
||||
to = 8001
|
||||
}
|
||||
}
|
||||
|
||||
task "http" {
|
||||
driver = "docker"
|
||||
|
||||
service {
|
||||
name = "nomad-service-discovery-example-server"
|
||||
provider = "nomad"
|
||||
port = "www"
|
||||
# If you're running Nomad in dev mode, uncomment the following address_mode line to allow this service to be discovered
|
||||
# address_mode = "driver"
|
||||
|
||||
check {
|
||||
type = "http"
|
||||
path = "/"
|
||||
interval = "5s"
|
||||
timeout = "1s"
|
||||
}
|
||||
}
|
||||
|
||||
config {
|
||||
image = "busybox:1"
|
||||
command = "httpd"
|
||||
args = ["-v", "-f", "-p", "\${NOMAD_PORT_www}", "-h", "/local"]
|
||||
ports = ["www"]
|
||||
}
|
||||
|
||||
template {
|
||||
data = <<EOF
|
||||
hello world
|
||||
EOF
|
||||
destination = "local/index.html"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 10
|
||||
memory = 50
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
|
@ -0,0 +1,48 @@
|
|||
/* eslint-disable */
|
||||
export default `# Use Nomad Variables to modify this job's output:
|
||||
# run "nomad var put nomad/jobs/variables-example name=YOUR_NAME" to get started
|
||||
|
||||
job "variables-example" {
|
||||
datacenters = ["dc1"]
|
||||
|
||||
group "web" {
|
||||
|
||||
network {
|
||||
# Task group will have an isolated network namespace with
|
||||
# an interface that is bridged with the host
|
||||
port "www" {
|
||||
to = 8001
|
||||
}
|
||||
}
|
||||
|
||||
service {
|
||||
provider = "nomad"
|
||||
port = "www"
|
||||
}
|
||||
|
||||
task "http" {
|
||||
|
||||
driver = "docker"
|
||||
|
||||
config {
|
||||
image = "busybox:1"
|
||||
command = "httpd"
|
||||
args = ["-v", "-f", "-p", "8001", "-h", "/local"]
|
||||
ports = ["www"]
|
||||
}
|
||||
|
||||
# Create a template resource that will be used to render the html file
|
||||
# using the Nomad variable at "nomad/jobs/variables-example"
|
||||
template {
|
||||
data = "<html>hello, {{ with nomadVar \\" nomad/jobs/variables-example \\" }}{{ .name }}{{ end }}</html>"
|
||||
destination = "local/index.html"
|
||||
}
|
||||
|
||||
resources {
|
||||
cpu = 128
|
||||
memory = 128
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}`;
|
|
@ -31,6 +31,12 @@ module.exports = function (defaults) {
|
|||
},
|
||||
hinting: isTest,
|
||||
tests: isTest,
|
||||
sassOptions: {
|
||||
precision: 4,
|
||||
includePaths: [
|
||||
'./node_modules/@hashicorp/design-system-tokens/dist/products/css',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Use `app.import` to add additional libraries to the generated
|
||||
|
|
|
@ -891,7 +891,12 @@ export default function () {
|
|||
|
||||
//#region Variables
|
||||
|
||||
this.get('/vars', function (schema, { queryParams: { namespace } }) {
|
||||
this.get('/vars', function (schema, { queryParams: { namespace, prefix } }) {
|
||||
if (prefix === 'nomad/job-templates') {
|
||||
return schema.variables
|
||||
.all()
|
||||
.filter((v) => v.path.includes('nomad/job-templates'));
|
||||
}
|
||||
if (namespace && namespace !== '*') {
|
||||
return schema.variables.all().filter((v) => v.namespace === namespace);
|
||||
} else {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { assign } from '@ember/polyfills';
|
||||
import config from 'nomad-ui/config/environment';
|
||||
import * as topoScenarios from './topo';
|
||||
import * as sysbatchScenarios from './sysbatch';
|
||||
|
@ -104,6 +105,54 @@ function smallCluster(server) {
|
|||
namespace: variableLinkedJob.namespace,
|
||||
});
|
||||
|
||||
const newJobName = 'new-job';
|
||||
const newJobTaskGroupName = 'redis';
|
||||
const jsonJob = (overrides) => {
|
||||
return JSON.stringify(
|
||||
assign(
|
||||
{},
|
||||
{
|
||||
Name: newJobName,
|
||||
Namespace: 'default',
|
||||
Datacenters: ['dc1'],
|
||||
Priority: 50,
|
||||
TaskGroups: [
|
||||
{
|
||||
Name: newJobTaskGroupName,
|
||||
Tasks: [
|
||||
{
|
||||
Name: 'redis',
|
||||
Driver: 'docker',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides
|
||||
),
|
||||
null,
|
||||
2
|
||||
);
|
||||
};
|
||||
|
||||
server.create('variable', {
|
||||
id: `nomad/job-templates/foo-bar`,
|
||||
namespace: 'namespace-2',
|
||||
Items: {
|
||||
description: 'a description',
|
||||
template: jsonJob(),
|
||||
},
|
||||
});
|
||||
|
||||
server.create('variable', {
|
||||
id: `nomad/job-templates/baz-qud`,
|
||||
namespace: 'default',
|
||||
Items: {
|
||||
description: 'another different description',
|
||||
template: jsonJob(),
|
||||
},
|
||||
});
|
||||
|
||||
server.create('variable', {
|
||||
id: 'Auto-conflicting Variable',
|
||||
namespace: 'default',
|
||||
|
|
|
@ -174,6 +174,7 @@
|
|||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@hashicorp/design-system-components": "^1.5.0",
|
||||
"@hashicorp/ember-flight-icons": "^2.0.12",
|
||||
"@percy/cli": "^1.6.1",
|
||||
"@percy/ember": "^3.0.0",
|
||||
|
|
|
@ -1,11 +1,24 @@
|
|||
import { currentURL } from '@ember/test-helpers';
|
||||
import AdapterError from '@ember-data/adapter/error';
|
||||
import {
|
||||
click,
|
||||
currentRouteName,
|
||||
currentURL,
|
||||
fillIn,
|
||||
visit,
|
||||
settled,
|
||||
} from '@ember/test-helpers';
|
||||
import { assign } from '@ember/polyfills';
|
||||
import { module, test } from 'qunit';
|
||||
import {
|
||||
selectChoose,
|
||||
clickTrigger,
|
||||
} from 'ember-power-select/test-support/helpers';
|
||||
import { setupApplicationTest } from 'ember-qunit';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
|
||||
import setupCodeMirror from 'nomad-ui/tests/helpers/codemirror';
|
||||
import JobRun from 'nomad-ui/tests/pages/jobs/run';
|
||||
import percySnapshot from '@percy/ember';
|
||||
|
||||
const newJobName = 'new-job';
|
||||
const newJobTaskGroupName = 'redis';
|
||||
|
@ -143,4 +156,439 @@ module('Acceptance | job run', function (hooks) {
|
|||
await JobRun.visit({ namespace: newNamespace });
|
||||
assert.equal(currentURL(), `/jobs/run?namespace=${newNamespace}`);
|
||||
});
|
||||
|
||||
module('job template flow', function () {
|
||||
test('allows user with the correct permissions to fill in the editor using a job template', async function (assert) {
|
||||
assert.expect(10);
|
||||
// Arrange
|
||||
await JobRun.visit();
|
||||
assert
|
||||
.dom('[data-test-choose-template]')
|
||||
.exists('A button allowing a user to select a template appears.');
|
||||
|
||||
server.get('/vars', function (_server, fakeRequest) {
|
||||
assert.deepEqual(
|
||||
fakeRequest.queryParams,
|
||||
{
|
||||
prefix: 'nomad/job-templates',
|
||||
namespace: '*',
|
||||
},
|
||||
'It makes a request to the /vars endpoint with the appropriate query parameters for job templates.'
|
||||
);
|
||||
return [
|
||||
{
|
||||
ID: 'nomad/job-templates/foo',
|
||||
Namespace: 'default',
|
||||
Path: 'nomad/job-templates/foo',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
server.get(
|
||||
'/var/nomad%2Fjob-templates%2Ffoo',
|
||||
function (_server, fakeRequest) {
|
||||
assert.deepEqual(
|
||||
fakeRequest.queryParams,
|
||||
{
|
||||
namespace: 'default',
|
||||
},
|
||||
'Dispatches O(n+1) query to retrive items.'
|
||||
);
|
||||
return {
|
||||
ID: 'nomad/job-templates/foo',
|
||||
Namespace: 'default',
|
||||
Path: 'nomad/job-templates/foo',
|
||||
Items: {
|
||||
template: 'Hello World!',
|
||||
label: 'foo',
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
// Act
|
||||
await click('[data-test-choose-template]');
|
||||
assert.equal(currentRouteName(), 'jobs.run.templates.index');
|
||||
|
||||
// Assert
|
||||
assert
|
||||
.dom('[data-test-template-list]')
|
||||
.exists('A list of available job templates is rendered.');
|
||||
assert
|
||||
.dom('[data-test-apply]')
|
||||
.exists('A button to apply the selected templated is displayed.');
|
||||
assert
|
||||
.dom('[data-test-cancel]')
|
||||
.exists('A button to cancel the template selection is displayed.');
|
||||
|
||||
await click('[data-test-template-card=Foo]');
|
||||
await click('[data-test-apply]');
|
||||
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
'/jobs/run?template=nomad%2Fjob-templates%2Ffoo%40default'
|
||||
);
|
||||
assert.dom('[data-test-editor]').containsText('Hello World!');
|
||||
});
|
||||
|
||||
test('a user can create their own job template', async function (assert) {
|
||||
assert.expect(7);
|
||||
// Arrange
|
||||
await JobRun.visit();
|
||||
await click('[data-test-choose-template]');
|
||||
|
||||
// Assert
|
||||
assert
|
||||
.dom('[data-test-template-card]')
|
||||
.exists({ count: 4 }, 'A list of default job templates is rendered.');
|
||||
|
||||
await click('[data-test-create-new-button]');
|
||||
assert.equal(currentRouteName(), 'jobs.run.templates.new');
|
||||
|
||||
await fillIn('[data-test-template-name]', 'foo');
|
||||
await fillIn('[data-test-template-description]', 'foo-bar-baz');
|
||||
const codeMirror = getCodeMirrorInstance('[data-test-template-json]');
|
||||
codeMirror.setValue(jsonJob());
|
||||
|
||||
server.put('/var/:varId', function (_server, fakeRequest) {
|
||||
assert.deepEqual(
|
||||
JSON.parse(fakeRequest.requestBody),
|
||||
{
|
||||
Path: 'nomad/job-templates/foo',
|
||||
CreateIndex: null,
|
||||
ModifyIndex: null,
|
||||
Namespace: 'default',
|
||||
ID: 'nomad/job-templates/foo',
|
||||
Items: { description: 'foo-bar-baz', template: jsonJob() },
|
||||
},
|
||||
'It makes a PUT request to the /vars/:varId endpoint with the appropriate request body for job templates.'
|
||||
);
|
||||
return {
|
||||
Items: { description: 'foo-bar-baz', template: jsonJob() },
|
||||
Namespace: 'default',
|
||||
Path: 'nomad/job-templates/foo',
|
||||
};
|
||||
});
|
||||
|
||||
server.get('/vars', function (_server, fakeRequest) {
|
||||
assert.deepEqual(
|
||||
fakeRequest.queryParams,
|
||||
{
|
||||
prefix: 'nomad/job-templates',
|
||||
namespace: '*',
|
||||
},
|
||||
'It makes a request to the /vars endpoint with the appropriate query parameters for job templates.'
|
||||
);
|
||||
return [
|
||||
{
|
||||
ID: 'nomad/job-templates/foo',
|
||||
Namespace: 'default',
|
||||
Path: 'nomad/job-templates/foo',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
server.get(
|
||||
'/var/nomad%2Fjob-templates%2Ffoo',
|
||||
function (_server, fakeRequest) {
|
||||
assert.deepEqual(
|
||||
fakeRequest.queryParams,
|
||||
{
|
||||
namespace: 'default',
|
||||
},
|
||||
'Dispatches O(n+1) query to retrive items.'
|
||||
);
|
||||
return {
|
||||
ID: 'nomad/job-templates/foo',
|
||||
Namespace: 'default',
|
||||
Path: 'nomad/job-templates/foo',
|
||||
Items: {
|
||||
template: 'qud',
|
||||
label: 'foo',
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
await click('[data-test-save-template]');
|
||||
assert.equal(currentRouteName(), 'jobs.run.templates.index');
|
||||
assert
|
||||
.dom('[data-test-template-card=Foo]')
|
||||
.exists('The newly created template appears in the list.');
|
||||
});
|
||||
|
||||
test('a toast notification alerts the user if there is an error saving the newly created job template', async function (assert) {
|
||||
assert.expect(5);
|
||||
// Arrange
|
||||
await JobRun.visit();
|
||||
await click('[data-test-choose-template]');
|
||||
|
||||
// Assert
|
||||
assert
|
||||
.dom('[data-test-template-card]')
|
||||
.exists({ count: 4 }, 'A list of default job templates is rendered.');
|
||||
|
||||
await click('[data-test-create-new-button]');
|
||||
assert.equal(currentRouteName(), 'jobs.run.templates.new');
|
||||
assert
|
||||
.dom('[data-test-save-template]')
|
||||
.isDisabled('the save button should be disabled if no path is set');
|
||||
|
||||
await fillIn('[data-test-template-name]', 'try@');
|
||||
await fillIn('[data-test-template-description]', 'foo-bar-baz');
|
||||
const codeMirror = getCodeMirrorInstance('[data-test-template-json]');
|
||||
codeMirror.setValue(jsonJob());
|
||||
|
||||
server.put('/var/:varId?cas=0', function () {
|
||||
return new AdapterError({
|
||||
detail: `invalid path "nomad/job-templates/try@"`,
|
||||
status: 500,
|
||||
});
|
||||
});
|
||||
|
||||
await click('[data-test-save-template]');
|
||||
assert.equal(
|
||||
currentRouteName(),
|
||||
'jobs.run.templates.new',
|
||||
'We do not navigate away from the page if an error is returned by the API.'
|
||||
);
|
||||
assert
|
||||
.dom('.flash-message.alert-error')
|
||||
.exists('A toast error message pops up.');
|
||||
});
|
||||
|
||||
test('a user cannot create a job template if one with the same name and namespace already exists', async function (assert) {
|
||||
assert.expect(4);
|
||||
// Arrange
|
||||
await JobRun.visit();
|
||||
await click('[data-test-choose-template]');
|
||||
server.create('variable', {
|
||||
path: 'nomad/job-templates/foo',
|
||||
namespace: 'default',
|
||||
id: 'nomad/job-templates/foo',
|
||||
});
|
||||
server.create('namespace', { id: 'test' });
|
||||
|
||||
this.system = this.owner.lookup('service:system');
|
||||
this.system.shouldShowNamespaces = true;
|
||||
|
||||
// Assert
|
||||
assert
|
||||
.dom('[data-test-template-card]')
|
||||
.exists({ count: 4 }, 'A list of default job templates is rendered.');
|
||||
|
||||
await click('[data-test-create-new-button]');
|
||||
assert.equal(currentRouteName(), 'jobs.run.templates.new');
|
||||
|
||||
await fillIn('[data-test-template-name]', 'foo');
|
||||
assert
|
||||
.dom('[data-test-duplicate-error]')
|
||||
.exists('an error message alerts the user');
|
||||
|
||||
await clickTrigger('[data-test-namespace-facet]');
|
||||
await selectChoose('[data-test-namespace-facet]', 'test');
|
||||
|
||||
assert
|
||||
.dom('[data-test-duplicate-error]')
|
||||
.doesNotExist(
|
||||
'an error disappears when name or namespace combination is unique'
|
||||
);
|
||||
|
||||
// Clean-up
|
||||
this.system.shouldShowNamespaces = false;
|
||||
});
|
||||
|
||||
test('a user can save code from the editor as a template', async function (assert) {
|
||||
assert.expect(4);
|
||||
// Arrange
|
||||
await JobRun.visit();
|
||||
await JobRun.editor.editor.fillIn(jsonJob());
|
||||
|
||||
await click('[data-test-save-as-template]');
|
||||
assert.equal(
|
||||
currentRouteName(),
|
||||
'jobs.run.templates.new',
|
||||
'We navigate template creation page.'
|
||||
);
|
||||
|
||||
// Assert
|
||||
assert
|
||||
.dom('[data-test-template-name]')
|
||||
.hasNoText('No template name is prefilled.');
|
||||
assert
|
||||
.dom('[data-test-template-description]')
|
||||
.hasNoText('No template description is prefilled.');
|
||||
|
||||
const codeMirror = getCodeMirrorInstance('[data-test-template-json]');
|
||||
const json = codeMirror.getValue();
|
||||
|
||||
assert.equal(
|
||||
json,
|
||||
jsonJob(),
|
||||
'Template is filled out with text from the editor.'
|
||||
);
|
||||
});
|
||||
|
||||
test('a user can edit a template', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
// Arrange
|
||||
server.create('variable', {
|
||||
path: 'nomad/job-templates/foo',
|
||||
namespace: 'default',
|
||||
id: 'nomad/job-templates/foo',
|
||||
Items: {},
|
||||
});
|
||||
|
||||
await visit('/jobs/run/templates/manage');
|
||||
|
||||
assert.equal(currentRouteName(), 'jobs.run.templates.manage');
|
||||
assert
|
||||
.dom('[data-test-template-list]')
|
||||
.exists('A list of templates is visible');
|
||||
await percySnapshot(assert);
|
||||
await click('[data-test-edit-template="nomad/job-templates/foo"]');
|
||||
assert.equal(
|
||||
currentRouteName(),
|
||||
'jobs.run.templates.template',
|
||||
'Navigates to edit template view'
|
||||
);
|
||||
|
||||
server.put('/var/:varId', function (_server, fakeRequest) {
|
||||
assert.deepEqual(
|
||||
JSON.parse(fakeRequest.requestBody),
|
||||
{
|
||||
Path: 'nomad/job-templates/foo',
|
||||
CreateIndex: null,
|
||||
ModifyIndex: null,
|
||||
Namespace: 'default',
|
||||
ID: 'nomad/job-templates/foo',
|
||||
Items: { description: 'baz qud thud' },
|
||||
},
|
||||
'It makes a PUT request to the /vars/:varId endpoint with the appropriate request body for job templates.'
|
||||
);
|
||||
|
||||
return {
|
||||
Items: { description: 'baz qud thud' },
|
||||
Namespace: 'default',
|
||||
Path: 'nomad/job-templates/foo',
|
||||
};
|
||||
});
|
||||
|
||||
await fillIn('[data-test-template-description]', 'baz qud thud');
|
||||
await click('[data-test-edit-template]');
|
||||
|
||||
assert.equal(
|
||||
currentRouteName(),
|
||||
'jobs.run.templates.index',
|
||||
'We navigate back to the templates view.'
|
||||
);
|
||||
});
|
||||
|
||||
test('a user can delete a template', async function (assert) {
|
||||
assert.expect(5);
|
||||
|
||||
// Arrange
|
||||
server.create('variable', {
|
||||
path: 'nomad/job-templates/foo',
|
||||
namespace: 'default',
|
||||
id: 'nomad/job-templates/foo',
|
||||
Items: {},
|
||||
});
|
||||
|
||||
server.create('variable', {
|
||||
path: 'nomad/job-templates/bar',
|
||||
namespace: 'default',
|
||||
id: 'nomad/job-templates/bar',
|
||||
Items: {},
|
||||
});
|
||||
|
||||
server.create('variable', {
|
||||
path: 'nomad/job-templates/baz',
|
||||
namespace: 'default',
|
||||
id: 'nomad/job-templates/baz',
|
||||
Items: {},
|
||||
});
|
||||
|
||||
await visit('/jobs/run/templates/manage');
|
||||
|
||||
assert.equal(currentRouteName(), 'jobs.run.templates.manage');
|
||||
assert
|
||||
.dom('[data-test-template-list]')
|
||||
.exists('A list of templates is visible');
|
||||
|
||||
await click('[data-test-idle-button]');
|
||||
await click('[data-test-confirm-button]');
|
||||
assert
|
||||
.dom('[data-test-edit-template="nomad/job-templates/foo"]')
|
||||
.doesNotExist('The template is removed from the list.');
|
||||
|
||||
await click('[data-test-edit-template="nomad/job-templates/bar"]');
|
||||
await click('[data-test-idle-button]');
|
||||
await click('[data-test-confirm-button]');
|
||||
|
||||
assert.equal(
|
||||
currentRouteName(),
|
||||
'jobs.run.templates.manage',
|
||||
'We navigate back to the templates manager view.'
|
||||
);
|
||||
|
||||
assert
|
||||
.dom('[data-test-edit-template="nomad/job-templates/bar"]')
|
||||
.doesNotExist('The template is removed from the list.');
|
||||
});
|
||||
|
||||
test('a user sees accurate template information', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
// Arrange
|
||||
server.create('variable', {
|
||||
path: 'nomad/job-templates/foo',
|
||||
namespace: 'default',
|
||||
id: 'nomad/job-templates/foo',
|
||||
Items: {
|
||||
template: 'qud',
|
||||
label: 'foo',
|
||||
description: 'bar baz',
|
||||
},
|
||||
});
|
||||
|
||||
await visit('/jobs/run/templates');
|
||||
|
||||
assert.equal(currentRouteName(), 'jobs.run.templates.index');
|
||||
assert.dom('[data-test-template-card="Foo"]').exists();
|
||||
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.store.unloadAll();
|
||||
await settled();
|
||||
|
||||
assert
|
||||
.dom('[data-test-template-card="Foo"]')
|
||||
.doesNotExist(
|
||||
'The template reactively updates to changes in the Ember Data Store.'
|
||||
);
|
||||
});
|
||||
|
||||
test('default templates', async function (assert) {
|
||||
assert.expect(4);
|
||||
const NUMBER_OF_DEFAULT_TEMPLATES = 4;
|
||||
|
||||
await visit('/jobs/run/templates');
|
||||
|
||||
assert.equal(currentRouteName(), 'jobs.run.templates.index');
|
||||
assert
|
||||
.dom('[data-test-template-card]')
|
||||
.exists({ count: NUMBER_OF_DEFAULT_TEMPLATES });
|
||||
|
||||
await percySnapshot(assert);
|
||||
|
||||
await click('[data-test-template-card="Hello world"]');
|
||||
await click('[data-test-apply]');
|
||||
|
||||
assert.equal(
|
||||
currentURL(),
|
||||
'/jobs/run?template=nomad%2Fjob-templates%2Fdefault%2Fhello-world'
|
||||
);
|
||||
assert.dom('[data-test-editor]').includesText('job "hello-world"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -509,6 +509,59 @@ module('Acceptance | variables', function (hooks) {
|
|||
'Cannot submit a variable that begins with nomad/<not-jobs>/'
|
||||
);
|
||||
|
||||
document.querySelector('[data-test-path-input]').value = ''; // clear current input
|
||||
await typeIn('[data-test-path-input]', 'nomad/jobs/');
|
||||
assert
|
||||
.dom('[data-test-submit-var]')
|
||||
.isNotDisabled('Can submit a variable that begins with nomad/jobs/');
|
||||
|
||||
document.querySelector('[data-test-path-input]').value = ''; // clear current input
|
||||
await typeIn('[data-test-path-input]', 'nomad/another-foo/');
|
||||
assert
|
||||
.dom('[data-test-submit-var]')
|
||||
.isDisabled('Disabled state re-evaluated when path input changes');
|
||||
|
||||
document.querySelector('[data-test-path-input]').value = ''; // clear current input
|
||||
await typeIn('[data-test-path-input]', 'nomad/jobs/job-templates/');
|
||||
assert
|
||||
.dom('[data-test-submit-var]')
|
||||
.isNotDisabled(
|
||||
'Can submit a variable that begins with nomad/job-templates/'
|
||||
);
|
||||
|
||||
// Reset Token
|
||||
window.localStorage.nomadTokenSecret = null;
|
||||
});
|
||||
|
||||
test('shows a custom editor when editing a job template variable', async function (assert) {
|
||||
// Arrange Test Set-up
|
||||
allScenarios.variableTestCluster(server);
|
||||
const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID);
|
||||
window.localStorage.nomadTokenSecret = variablesToken.secretId;
|
||||
await Variables.visitNew();
|
||||
// End Test Set-up
|
||||
|
||||
assert
|
||||
.dom('.related-entities-hint')
|
||||
.exists('Shows a hint about related entities by default');
|
||||
assert.dom('.CodeMirror').doesNotExist();
|
||||
await typeIn('[data-test-path-input]', 'nomad/job-templates/hello-world');
|
||||
assert
|
||||
.dom('.related-entities-hint')
|
||||
.doesNotExist('Hides the hint when editing a job template variable');
|
||||
assert
|
||||
.dom('.job-template-hint')
|
||||
.exists('Shows a hint about job templates');
|
||||
assert
|
||||
.dom('.CodeMirror')
|
||||
.exists('Shows a custom editor for job templates');
|
||||
|
||||
document.querySelector('[data-test-path-input]').value = ''; // clear current input
|
||||
await typeIn('[data-test-path-input]', 'hello-world-non-template');
|
||||
assert
|
||||
.dom('.related-entities-hint')
|
||||
.exists('Shows a hint about related entities by default');
|
||||
assert.dom('.CodeMirror').doesNotExist();
|
||||
// Reset Token
|
||||
window.localStorage.nomadTokenSecret = null;
|
||||
});
|
||||
|
|
203
ui/yarn.lock
203
ui/yarn.lock
|
@ -2568,6 +2568,15 @@
|
|||
"@embroider/shared-internals" "^1.5.0"
|
||||
semver "^7.3.5"
|
||||
|
||||
"@embroider/addon-shim@^1.5.0", "@embroider/addon-shim@^1.8.4":
|
||||
version "1.8.4"
|
||||
resolved "https://registry.yarnpkg.com/@embroider/addon-shim/-/addon-shim-1.8.4.tgz#0e7f32c5506bf0f3eb0840506e31c36c7053763c"
|
||||
integrity sha512-sFhfWC0vI18KxVenmswQ/ShIvBg4juL8ubI+Q3NTSdkCTeaPQ/DIOUF6oR5DCQ8eO/TkIaw+kdG3FkTY6yNJqA==
|
||||
dependencies:
|
||||
"@embroider/shared-internals" "^2.0.0"
|
||||
broccoli-funnel "^3.0.8"
|
||||
semver "^7.3.8"
|
||||
|
||||
"@embroider/core@0.36.0":
|
||||
version "0.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@embroider/core/-/core-0.36.0.tgz#fbbd60d29c3fcbe02b4e3e63e6043a43de2b9ce3"
|
||||
|
@ -2765,6 +2774,20 @@
|
|||
semver "^7.3.5"
|
||||
typescript-memoize "^1.0.1"
|
||||
|
||||
"@embroider/shared-internals@^2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@embroider/shared-internals/-/shared-internals-2.0.0.tgz#d8205ec6944362aeecfbb51143db352430ced316"
|
||||
integrity sha512-qZ2/xky9mWm5YC6noOa6AiAwgISEQ78YTZNv4SNu2PFgEK/H+Ha/3ddngzGSsnXkVnIHZyxIBzhxETonQYHY9g==
|
||||
dependencies:
|
||||
babel-import-util "^1.1.0"
|
||||
ember-rfc176-data "^0.3.17"
|
||||
fs-extra "^9.1.0"
|
||||
js-string-escape "^1.0.1"
|
||||
lodash "^4.17.21"
|
||||
resolve-package-path "^4.0.1"
|
||||
semver "^7.3.5"
|
||||
typescript-memoize "^1.0.1"
|
||||
|
||||
"@embroider/util@^0.39.1 || ^0.40.0 || ^0.41.0":
|
||||
version "0.41.0"
|
||||
resolved "https://registry.yarnpkg.com/@embroider/util/-/util-0.41.0.tgz#5324cb4742aa4ed8d613c4f88a466f73e4e6acc1"
|
||||
|
@ -3095,6 +3118,32 @@
|
|||
resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-1.1.0.tgz#d6dbc7574774b238114582410e8fee0dc3532bdf"
|
||||
integrity sha512-rR7tJoSwJ2eooOpYGxGGW95sLq6GXUaS1UtWvN7pei6n2/okYvCGld9vsUTvkl2migxbkszsycwtMf/GEc1k1A==
|
||||
|
||||
"@hashicorp/design-system-components@^1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/design-system-components/-/design-system-components-1.5.0.tgz#f9a61b4067d474883c08249cd2548d68c213510e"
|
||||
integrity sha512-aSAnJeK6ecBZGiEK8h7TXuDnD4xGJawpCkAQ/18V6yQKnjGnlTckiynSeF5kVdHHc1PenK+O9RXcrdiIuDw2XQ==
|
||||
dependencies:
|
||||
"@hashicorp/design-system-tokens" "^1.3.0"
|
||||
"@hashicorp/ember-flight-icons" "^3.0.2"
|
||||
dialog-polyfill "^0.5.6"
|
||||
ember-auto-import "^2.4.2"
|
||||
ember-cached-decorator-polyfill "^0.1.4"
|
||||
ember-cli-babel "^7.26.11"
|
||||
ember-cli-htmlbars "^6.1.0"
|
||||
ember-cli-sass "^10.0.1"
|
||||
ember-composable-helpers "^4.4.1"
|
||||
ember-focus-trap "^1.0.1"
|
||||
ember-keyboard "^8.1.0"
|
||||
ember-named-blocks-polyfill "^0.2.5"
|
||||
ember-style-modifier "^0.8.0"
|
||||
ember-truth-helpers "^3.0.0"
|
||||
sass "^1.43.4"
|
||||
|
||||
"@hashicorp/design-system-tokens@^1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/design-system-tokens/-/design-system-tokens-1.3.0.tgz#0a143f7fb20b72ac003e56c7408e23b0b55d45f9"
|
||||
integrity sha512-Re16f4tqyAApChk20Eev0xVWijCo8m3YYIo4A84tStTTkXk4d63lbY1MuebWaaAjcjzKGuyPh0w3u0SJWJFCUg==
|
||||
|
||||
"@hashicorp/ember-flight-icons@^2.0.12":
|
||||
version "2.0.12"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-2.0.12.tgz#788adf7a4fedc468d612d35b604255df948f4012"
|
||||
|
@ -3104,11 +3153,26 @@
|
|||
ember-cli-babel "^7.26.11"
|
||||
ember-cli-htmlbars "^6.0.1"
|
||||
|
||||
"@hashicorp/ember-flight-icons@^3.0.2":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-3.0.2.tgz#8d884c07842a6c88df18ca680d7883a59222a2ba"
|
||||
integrity sha512-WomQg1hw/IHA1N9hC77WbTNazVXqu2RdRoaVCGT99NTXQ4S7Bw7vhHheR4JAgt10ksMZFI3X/bJVHxFfjUCkSQ==
|
||||
dependencies:
|
||||
"@hashicorp/flight-icons" "^2.12.0"
|
||||
ember-auto-import "^2.4.2"
|
||||
ember-cli-babel "^7.26.11"
|
||||
ember-cli-htmlbars "^6.1.0"
|
||||
|
||||
"@hashicorp/flight-icons@^2.10.0":
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.10.0.tgz#24b03043bacda16e505200e6591dfef896ddacf1"
|
||||
integrity sha512-jYUA0M6Tz+4RAudil+GW/fHbhZPcKCiIZZAguBDviqbLneMkMgPOBgbXWCGWsEQ1fJzP2cXbUaio8L0aQZPWQw==
|
||||
|
||||
"@hashicorp/flight-icons@^2.12.0":
|
||||
version "2.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.12.0.tgz#48bc21f21678668ffe9147b181a2991d8b151fc7"
|
||||
integrity sha512-PhjTTHCjoq4EJirifbxLxnxXnCRf1NUAYZ1WnFW8i0yOmmax6fgjsJRPlf0VIGsR8R7isFpjuy6gJ5c7mNhE0w==
|
||||
|
||||
"@hashicorp/structure-icons@^1.3.0":
|
||||
version "1.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@hashicorp/structure-icons/-/structure-icons-1.9.2.tgz#c75f955b2eec414ecb92f3926c79b4ca01731d3c"
|
||||
|
@ -8965,6 +9029,11 @@ detect-port@^1.3.0:
|
|||
address "^1.0.1"
|
||||
debug "^2.6.0"
|
||||
|
||||
dialog-polyfill@^0.5.6:
|
||||
version "0.5.6"
|
||||
resolved "https://registry.yarnpkg.com/dialog-polyfill/-/dialog-polyfill-0.5.6.tgz#7507b4c745a82fcee0fa07ce64d835979719599a"
|
||||
integrity sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w==
|
||||
|
||||
diff@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
|
||||
|
@ -9255,7 +9324,7 @@ ember-assign-helper@^0.3.0:
|
|||
ember-cli-babel "^7.19.0"
|
||||
ember-cli-htmlbars "^4.3.1"
|
||||
|
||||
ember-auto-import@^1.10.1, ember-auto-import@^1.11.3, ember-auto-import@^1.2.19, ember-auto-import@^1.6.0, ember-auto-import@^2.2.4, ember-auto-import@^2.4.0:
|
||||
ember-auto-import@^1.10.1, ember-auto-import@^1.11.3, ember-auto-import@^1.2.19, ember-auto-import@^1.6.0, ember-auto-import@^2.2.4, ember-auto-import@^2.4.0, ember-auto-import@^2.4.2:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-auto-import/-/ember-auto-import-2.4.0.tgz#91c4797f08315728086e35af954cb60bd23c14bc"
|
||||
integrity sha512-BwF6iTaoSmT2vJ9NEHEGRBCh2+qp+Nlaz/Q7roqNSxl5oL5iMRwenPnHhOoBPTYZvPhcV/KgXR5e+pBQ107plQ==
|
||||
|
@ -9309,6 +9378,26 @@ ember-basic-dropdown@^3.0.21:
|
|||
ember-style-modifier "^0.7.0"
|
||||
ember-truth-helpers "^2.1.0 || ^3.0.0"
|
||||
|
||||
ember-cache-primitive-polyfill@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ember-cache-primitive-polyfill/-/ember-cache-primitive-polyfill-1.0.1.tgz#a27075443bd87e5af286c1cd8a7df24e3b9f6715"
|
||||
integrity sha512-hSPcvIKarA8wad2/b6jDd/eU+OtKmi6uP+iYQbzi5TQpjsqV6b4QdRqrLk7ClSRRKBAtdTuutx+m+X+WlEd2lw==
|
||||
dependencies:
|
||||
ember-cli-babel "^7.22.1"
|
||||
ember-cli-version-checker "^5.1.1"
|
||||
ember-compatibility-helpers "^1.2.1"
|
||||
silent-error "^1.1.1"
|
||||
|
||||
ember-cached-decorator-polyfill@^0.1.4:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/ember-cached-decorator-polyfill/-/ember-cached-decorator-polyfill-0.1.4.tgz#f1e2c65cc78d0d9c4ac0e047e643af477eb85ace"
|
||||
integrity sha512-JOK7kBCWsTVCzmCefK4nr9BACDJk0owt9oIUaVt6Q0UtQ4XeAHmoK5kQ/YtDcxQF1ZevHQFdGhsTR3JLaHNJgA==
|
||||
dependencies:
|
||||
"@glimmer/tracking" "^1.0.4"
|
||||
ember-cache-primitive-polyfill "^1.0.1"
|
||||
ember-cli-babel "^7.21.0"
|
||||
ember-cli-babel-plugin-helpers "^1.1.1"
|
||||
|
||||
ember-can@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-can/-/ember-can-4.1.0.tgz#af3bb7838eab381e56977772dbb75bf5e16e77a9"
|
||||
|
@ -9571,6 +9660,26 @@ ember-cli-htmlbars@^6.0.0, ember-cli-htmlbars@^6.0.1:
|
|||
strip-bom "^4.0.0"
|
||||
walk-sync "^2.2.0"
|
||||
|
||||
ember-cli-htmlbars@^6.1.0:
|
||||
version "6.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-htmlbars/-/ember-cli-htmlbars-6.1.1.tgz#f5b588572a5d18ad087560122b8dabc90145173d"
|
||||
integrity sha512-DKf2rjzIVw9zWCuFsBGJScrgf5Mz7dSg08Cq+FWFYIxnpssINUbNUoB0NHWnUJK4QqCvaExOyOmjm0kO455CPg==
|
||||
dependencies:
|
||||
"@ember/edition-utils" "^1.2.0"
|
||||
babel-plugin-ember-template-compilation "^1.0.0"
|
||||
babel-plugin-htmlbars-inline-precompile "^5.3.0"
|
||||
broccoli-debug "^0.6.5"
|
||||
broccoli-persistent-filter "^3.1.2"
|
||||
broccoli-plugin "^4.0.3"
|
||||
ember-cli-version-checker "^5.1.2"
|
||||
fs-tree-diff "^2.0.1"
|
||||
hash-for-dep "^1.5.1"
|
||||
heimdalljs-logger "^0.1.10"
|
||||
js-string-escape "^1.0.1"
|
||||
semver "^7.3.4"
|
||||
silent-error "^1.1.1"
|
||||
walk-sync "^2.2.0"
|
||||
|
||||
ember-cli-import-polyfill@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-import-polyfill/-/ember-cli-import-polyfill-0.2.0.tgz#c1a08a8affb45c97b675926272fe78cf4ca166f2"
|
||||
|
@ -9664,7 +9773,7 @@ ember-cli-preprocess-registry@^3.3.0:
|
|||
debug "^3.0.1"
|
||||
process-relative-require "^1.0.0"
|
||||
|
||||
ember-cli-sass@^10.0.0:
|
||||
ember-cli-sass@^10.0.0, ember-cli-sass@^10.0.1:
|
||||
version "10.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ember-cli-sass/-/ember-cli-sass-10.0.1.tgz#afa91eb7dfe3890be0390639d66976512e7d8edc"
|
||||
integrity sha512-dWVoX03O2Mot1dEB1AN3ofC8DDZb6iU4Kfkbr3WYi9S9bGVHrpR/ngsR7tuVBuTugTyG53FPtLLqYdqx7XjXdA==
|
||||
|
@ -9964,6 +10073,16 @@ ember-compatibility-helpers@^1.2.0, ember-compatibility-helpers@^1.2.1:
|
|||
ember-cli-version-checker "^5.1.1"
|
||||
semver "^5.4.1"
|
||||
|
||||
ember-composable-helpers@^4.4.1:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-composable-helpers/-/ember-composable-helpers-4.5.0.tgz#94febbdf4348e64f45f7a6f993f326e32540a61e"
|
||||
integrity sha512-XjpDLyVPsLCy6kd5dIxZonOECCO6AA5sY5Hr6tYUbJg3s5ghFAiFWaNcYraYC+fL2yPJQAswwpfwGlQORUJZkw==
|
||||
dependencies:
|
||||
"@babel/core" "^7.0.0"
|
||||
broccoli-funnel "2.0.1"
|
||||
ember-cli-babel "^7.26.3"
|
||||
resolve "^1.10.0"
|
||||
|
||||
ember-composable-helpers@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-composable-helpers/-/ember-composable-helpers-5.0.0.tgz#055bab3a3e234ab2917499b1465e968c253ca885"
|
||||
|
@ -10120,6 +10239,14 @@ ember-fetch@^8.1.1:
|
|||
node-fetch "^2.6.1"
|
||||
whatwg-fetch "^3.6.2"
|
||||
|
||||
ember-focus-trap@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ember-focus-trap/-/ember-focus-trap-1.0.1.tgz#a99565f6ce55d500b92a0965e79e3ad04219f157"
|
||||
integrity sha512-ZUyq5ZkIuXp+ng9rCMkqBh36/V95PltL7iljStkma4+651xlAy3Z84L9WOu/uOJyVpNUxii8RJBbAySHV6c+RQ==
|
||||
dependencies:
|
||||
"@embroider/addon-shim" "^1.0.0"
|
||||
focus-trap "^6.7.1"
|
||||
|
||||
ember-get-config@, "ember-get-config@^0.2.4 || ^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-get-config/-/ember-get-config-0.3.0.tgz#a73a1a87b48d9dde4c66a0e52ed5260b8a48cfbd"
|
||||
|
@ -10169,6 +10296,16 @@ ember-inline-svg@^1.0.1:
|
|||
svgo "~1.2.2"
|
||||
walk-sync "~2.0.2"
|
||||
|
||||
ember-keyboard@^8.1.0:
|
||||
version "8.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-keyboard/-/ember-keyboard-8.2.0.tgz#d11fa7f0443606b7c1850bbd8253274a00046e11"
|
||||
integrity sha512-h2kuS2irtIyvNbAMkGDlDTB4TPXwgmC6Nu9bIuGWoCjkGdgJbUg0VegfyRJ1TlxbIHlAelbqVpE8UhfgY5wEag==
|
||||
dependencies:
|
||||
"@embroider/addon-shim" "^1.5.0"
|
||||
ember-destroyable-polyfill "^2.0.3"
|
||||
ember-modifier "^2.1.2 || ^3.1.0 || ^4.0.0"
|
||||
ember-modifier-manager-polyfill "^1.2.0"
|
||||
|
||||
ember-load-initializers@^2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ember-load-initializers/-/ember-load-initializers-2.1.2.tgz#8a47a656c1f64f9b10cecdb4e22a9d52ad9c7efa"
|
||||
|
@ -10206,7 +10343,7 @@ ember-modifier-manager-polyfill@^1.2.0:
|
|||
ember-cli-version-checker "^2.1.2"
|
||||
ember-compatibility-helpers "^1.2.0"
|
||||
|
||||
ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0:
|
||||
ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0, ember-modifier@^3.2.7:
|
||||
version "3.2.7"
|
||||
resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-3.2.7.tgz#f2d35b7c867cbfc549e1acd8d8903c5ecd02ea4b"
|
||||
integrity sha512-ezcPQhH8jUfcJQbbHji4/ZG/h0yyj1jRDknfYue/ypQS8fM8LrGcCMo0rjDZLzL1Vd11InjNs3BD7BdxFlzGoA==
|
||||
|
@ -10217,6 +10354,15 @@ ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0:
|
|||
ember-cli-typescript "^5.0.0"
|
||||
ember-compatibility-helpers "^1.2.5"
|
||||
|
||||
"ember-modifier@^2.1.2 || ^3.1.0 || ^4.0.0":
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-4.0.0.tgz#0bb3fae11435fcbe0d3dfa852ce224d81d75ddb2"
|
||||
integrity sha512-OdconmrqKP2haK4kBwNmtnA2NiC2MFmIJC3LgJ1WhwZ49GaktM+bRIuFxF/S5W0oaegzKs1qH2ZDlqMeO2L3nw==
|
||||
dependencies:
|
||||
"@embroider/addon-shim" "^1.8.4"
|
||||
ember-cli-normalize-entity-name "^1.0.0"
|
||||
ember-cli-string-utils "^1.1.0"
|
||||
|
||||
ember-moment@^9.0.1:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ember-moment/-/ember-moment-9.0.1.tgz#fcf06cb8ef07c8d0108820c1639778590d613b38"
|
||||
|
@ -10236,6 +10382,14 @@ ember-named-blocks-polyfill@^0.2.4:
|
|||
ember-cli-babel "^7.19.0"
|
||||
ember-cli-version-checker "^5.1.1"
|
||||
|
||||
ember-named-blocks-polyfill@^0.2.5:
|
||||
version "0.2.5"
|
||||
resolved "https://registry.yarnpkg.com/ember-named-blocks-polyfill/-/ember-named-blocks-polyfill-0.2.5.tgz#d5841406277026a221f479c815cfbac6cdcaeecb"
|
||||
integrity sha512-OVMxzkfqJrEvmiky7gFzmuTaImCGm7DOudHWTdMBPO7E+dQSunrcRsJMgO9ZZ56suqBIz/yXbEURrmGS+avHxA==
|
||||
dependencies:
|
||||
ember-cli-babel "^7.19.0"
|
||||
ember-cli-version-checker "^5.1.1"
|
||||
|
||||
ember-on-resize-modifier@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-on-resize-modifier/-/ember-on-resize-modifier-1.0.0.tgz#b4e12dc023b4d608d7b0f4fa0100722fb860cdd4"
|
||||
|
@ -10436,6 +10590,14 @@ ember-style-modifier@^0.7.0:
|
|||
ember-cli-babel "^7.26.6"
|
||||
ember-modifier "^3.0.0"
|
||||
|
||||
ember-style-modifier@^0.8.0:
|
||||
version "0.8.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-style-modifier/-/ember-style-modifier-0.8.0.tgz#ef46b3f288e63e3d850418ea8dc6f7b12edde721"
|
||||
integrity sha512-I7M+oZ+poYYOP7n521rYv7kkYZbxotL8VbtHYxLQ3tasRZYQJ21qfu3vVjydSjwyE3w7EZRgKngBoMhKSAEZnw==
|
||||
dependencies:
|
||||
ember-cli-babel "^7.26.6"
|
||||
ember-modifier "^3.2.7"
|
||||
|
||||
ember-template-lint@^3.15.0:
|
||||
version "3.16.0"
|
||||
resolved "https://registry.yarnpkg.com/ember-template-lint/-/ember-template-lint-3.16.0.tgz#7af2ec8d4386f4726be08c14c39ba121c56f0896"
|
||||
|
@ -11647,6 +11809,13 @@ flush-write-stream@^1.0.0:
|
|||
inherits "^2.0.3"
|
||||
readable-stream "^2.3.6"
|
||||
|
||||
focus-trap@^6.7.1:
|
||||
version "6.9.4"
|
||||
resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-6.9.4.tgz#436da1a1d935c48b97da63cd8f361c6f3aa16444"
|
||||
integrity sha512-v2NTsZe2FF59Y+sDykKY+XjqZ0cPfhq/hikWVL88BqLivnNiEffAsac6rP6H45ff9wG9LL5ToiDqrLEP9GX9mw==
|
||||
dependencies:
|
||||
tabbable "^5.3.3"
|
||||
|
||||
follow-redirects@^1.0.0:
|
||||
version "1.14.8"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
|
||||
|
@ -12893,6 +13062,11 @@ immer@8.0.1:
|
|||
resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656"
|
||||
integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==
|
||||
|
||||
immutable@^4.0.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef"
|
||||
integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==
|
||||
|
||||
import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
|
||||
|
@ -17469,6 +17643,15 @@ sass@^1.17.3:
|
|||
dependencies:
|
||||
chokidar ">=3.0.0 <4.0.0"
|
||||
|
||||
sass@^1.43.4:
|
||||
version "1.57.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.57.0.tgz#64c4144ed4e1c0ccb96dc18aef2c424cdbc0c12b"
|
||||
integrity sha512-IZNEJDTK1cF5B1cGA593TPAV/1S0ysUDxq9XHjX/+SMy0QfUny+nfUsq5ZP7wWSl4eEf7wDJcEZ8ABYFmh3m/w==
|
||||
dependencies:
|
||||
chokidar ">=3.0.0 <4.0.0"
|
||||
immutable "^4.0.0"
|
||||
source-map-js ">=0.6.2 <2.0.0"
|
||||
|
||||
sax@~1.2.4:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||
|
@ -17579,6 +17762,13 @@ semver@^7.2.1, semver@^7.3.2:
|
|||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
semver@^7.3.8:
|
||||
version "7.3.8"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
|
||||
integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
send@0.17.1:
|
||||
version "0.17.1"
|
||||
resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
|
||||
|
@ -17898,7 +18088,7 @@ source-list-map@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
|
||||
integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==
|
||||
|
||||
source-map-js@^1.0.1, source-map-js@^1.0.2:
|
||||
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.1, source-map-js@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
|
||||
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
|
||||
|
@ -18524,6 +18714,11 @@ sync-disk-cache@^2.0.0:
|
|||
rimraf "^3.0.0"
|
||||
username-sync "^1.0.2"
|
||||
|
||||
tabbable@^5.3.3:
|
||||
version "5.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf"
|
||||
integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==
|
||||
|
||||
table@^6.0.9:
|
||||
version "6.7.5"
|
||||
resolved "https://registry.yarnpkg.com/table/-/table-6.7.5.tgz#f04478c351ef3d8c7904f0e8be90a1b62417d238"
|
||||
|
|
|
@ -29,6 +29,8 @@ template.
|
|||
|
||||
- `-short`: If set, a minimal jobspec without comments is emitted.
|
||||
- `-connect`: If set, the jobspec includes Consul Connect integration.
|
||||
- `-template=<template>`: Specifies a predefined template to emit. Must be a Nomad Variable that lives at `nomad/job-templates/<template>` These are commonly created via the UI, and accessible with the -list-templates flag.
|
||||
- `-list-templates`: Display a list of possible job templates to pass to -template. Reads from all variables pathed at `nomad/job-templates/<template>`.
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
Loading…
Reference in New Issue