Merge pull request #619 from ansible-semaphore/web2

New web UI with using Vue.js
This commit is contained in:
Denis Gukov 2020-11-05 17:57:57 +05:00 committed by GitHub
commit 35ff8782a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 18640 additions and 10 deletions

View File

@ -54,7 +54,7 @@ aliases:
- &test-compile-changes
run:
name: test that compile did not create/modify untracked files
command: git diff --exit-code --stat -- . ':(exclude)web/package-lock.json' ':(exclude)go.mod' ':(exclude)go.sum'
command: git diff --exit-code --stat -- . ':(exclude)web2/package-lock.json' ':(exclude)web/package-lock.json' ':(exclude)go.mod' ':(exclude)go.sum'
- &save-npm-cache
save_cache:

View File

@ -95,6 +95,7 @@ func main() {
addCapabilities([]string{"repository", "inventory", "environment"})
})
h.Before("project > /api/project/{project_id}/templates/{template_id} > Get template > 200 > application/json", capabilityWrapper("template"))
h.Before("project > /api/project/{project_id}/templates/{template_id} > Updates template > 204 > application/json", capabilityWrapper("template"))
h.Before("project > /api/project/{project_id}/templates/{template_id} > Removes template > 204 > application/json", capabilityWrapper("template"))

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ web/public/js/bundle.js
web/public/css/*.*
web/public/html/**/*.*
web/public/fonts/*.*
web2/dist
config.json
.DS_Store
node_modules/

View File

@ -26,6 +26,7 @@ tasks:
- task: deps:tools
- task: deps:be
- task: deps:fe
- task: deps:fe2
deps:be:
desc: Vendor application dependencies
@ -39,6 +40,13 @@ tasks:
- npm install
- npm audit fix
deps:fe2:
desc: Installs npm requirements for front end from package.json
dir: web2
cmds:
- npm install
- npm audit fix
deps:integration:
desc: Installs requirements for integration testing with dredd
dir: web
@ -68,6 +76,7 @@ tasks:
desc: Generates compiled frontend and backend resources (must be in this order)
cmds:
- task: compile:fe
- task: compile:fe2
- task: compile:be
compile:fe:
@ -87,9 +96,31 @@ tasks:
- '{{ if eq OS "windows" }} .\\node_modules\\.bin\\pug.cmd --pretty {{ else }} ./node_modules/.bin/pug {{ end }} resources/pug --out public/html'
- '{{ if eq OS "windows" }} xcopy node_modules\\font-awesome\\fonts public\\fonts /y {{ else }} cp node_modules/font-awesome/fonts/* public/fonts {{ end }}'
- node bundler.js
compile:fe2:
desc: Build vue.js project
dir: web2
sources:
- src/*.*
- src/**/*.*
- public/index.html
- public/favicon.ico
- package.json
- package-lock.json
- babel.config.js
- vue.config.js
generates:
- dist/css/*.css
- dist/js/*.js
- dist/index.html
- dist/favicon.ico
cmds:
- npm run build
compile:be:
desc: Runs Packr for static assets
sources:
- web2/dist/*
- web/public/*
- db/migrations/*
generates:

View File

@ -1175,6 +1175,15 @@ paths:
parameters:
- $ref: "#/parameters/project_id"
- $ref: "#/parameters/template_id"
get:
tags:
- project
summary: Get template
responses:
200:
description: template object
schema:
$ref: "#/definitions/Template"
put:
tags:
- project

View File

@ -45,6 +45,11 @@ func EnvironmentMiddleware(next http.Handler) http.Handler {
// GetEnvironment retrieves sorted environments from the database
func GetEnvironment(w http.ResponseWriter, r *http.Request) {
if environment := context.Get(r, "environment"); environment != nil {
util.WriteJSON(w, http.StatusOK, environment.(db.Environment))
return
}
project := context.Get(r, "project").(db.Project)
var env []db.Environment

View File

@ -52,7 +52,13 @@ func InventoryMiddleware(next http.Handler) http.Handler {
// GetInventory returns an inventory from the database
func GetInventory(w http.ResponseWriter, r *http.Request) {
if inventory := context.Get(r, "inventory"); inventory != nil {
util.WriteJSON(w, http.StatusOK, inventory.(db.Inventory))
return
}
project := context.Get(r, "project").(db.Project)
var inv []db.Inventory
sort := r.URL.Query().Get("sort")

View File

@ -37,6 +37,11 @@ func KeyMiddleware(next http.Handler) http.Handler {
// GetKeys retrieves sorted keys from the database
func GetKeys(w http.ResponseWriter, r *http.Request) {
if key := context.Get(r, "accessKey"); key != nil {
util.WriteJSON(w, http.StatusOK, key.(db.AccessKey))
return
}
project := context.Get(r, "project").(db.Project)
var keys []db.AccessKey

View File

@ -49,6 +49,11 @@ func RepositoryMiddleware(next http.Handler) http.Handler {
// GetRepositories returns all repositories in a project sorted by type
func GetRepositories(w http.ResponseWriter, r *http.Request) {
if repo := context.Get(r, "repository"); repo != nil {
util.WriteJSON(w, http.StatusOK, repo.(db.Repository))
return
}
project := context.Get(r, "project").(db.Project)
var repos []db.Repository

View File

@ -36,6 +36,12 @@ func TemplatesMiddleware(next http.Handler) http.Handler {
})
}
// GetTemplate returns single template by ID
func GetTemplate(w http.ResponseWriter, r *http.Request) {
template := context.Get(r, "template").(db.Template)
util.WriteJSON(w, http.StatusOK, template)
}
// GetTemplates returns all templates for a project in a sort order
func GetTemplates(w http.ResponseWriter, r *http.Request) {
project := context.Get(r, "project").(db.Project)

View File

@ -38,6 +38,11 @@ func UserMiddleware(next http.Handler) http.Handler {
// GetUsers returns all users in a project
func GetUsers(w http.ResponseWriter, r *http.Request) {
if user := context.Get(r, "projectUser"); user != nil {
util.WriteJSON(w, http.StatusOK, user.(db.User))
return
}
project := context.Get(r, "project").(db.Project)
var users []db.User

View File

@ -16,7 +16,15 @@ import (
"github.com/russross/blackfriday"
)
var publicAssets = packr.NewBox("../web/public")
var publicAssets packr.Box
func getPublicAssetsPath() string {
if util.Config != nil && util.Config.OldFrontend {
return "../web/public"
}
return "../web2/dist"
}
//JSONMiddleware ensures that all the routes respond with Json, this is added by default to all routes
func JSONMiddleware(next http.Handler) http.Handler {
@ -50,6 +58,8 @@ func notFoundHandler(w http.ResponseWriter, r *http.Request) {
// Route declares all routes
func Route() *mux.Router {
publicAssets = packr.NewBox(getPublicAssetsPath())
r := mux.NewRouter().StrictSlash(true)
r.NotFoundHandler = http.HandlerFunc(servePublic)
@ -140,6 +150,7 @@ func Route() *mux.Router {
projectUserManagement := projectAdminAPI.PathPrefix("/users").Subrouter()
projectUserManagement.Use(projects.UserMiddleware)
projectUserManagement.HandleFunc("/{user_id}", projects.GetUsers).Methods("GET", "HEAD")
projectUserManagement.HandleFunc("/{user_id}/admin", projects.MakeUserAdmin).Methods("POST")
projectUserManagement.HandleFunc("/{user_id}/admin", projects.MakeUserAdmin).Methods("DELETE")
projectUserManagement.HandleFunc("/{user_id}", projects.RemoveUser).Methods("DELETE")
@ -147,24 +158,28 @@ func Route() *mux.Router {
projectKeyManagement := projectAdminAPI.PathPrefix("/keys").Subrouter()
projectKeyManagement.Use(projects.KeyMiddleware)
projectKeyManagement.HandleFunc("/{key_id}", projects.GetKeys).Methods("GET", "HEAD")
projectKeyManagement.HandleFunc("/{key_id}", projects.UpdateKey).Methods("PUT")
projectKeyManagement.HandleFunc("/{key_id}", projects.RemoveKey).Methods("DELETE")
projectRepoManagement := projectUserAPI.PathPrefix("/repositories").Subrouter()
projectRepoManagement.Use(projects.RepositoryMiddleware)
projectRepoManagement.HandleFunc("/{repository_id}", projects.GetRepositories).Methods("GET", "HEAD")
projectRepoManagement.HandleFunc("/{repository_id}", projects.UpdateRepository).Methods("PUT")
projectRepoManagement.HandleFunc("/{repository_id}", projects.RemoveRepository).Methods("DELETE")
projectInventoryManagement := projectUserAPI.PathPrefix("/inventory").Subrouter()
projectInventoryManagement.Use(projects.InventoryMiddleware)
projectInventoryManagement.HandleFunc("/{inventory_id}", projects.GetInventory).Methods("GET", "HEAD")
projectInventoryManagement.HandleFunc("/{inventory_id}", projects.UpdateInventory).Methods("PUT")
projectInventoryManagement.HandleFunc("/{inventory_id}", projects.RemoveInventory).Methods("DELETE")
projectEnvManagement := projectUserAPI.PathPrefix("/environment").Subrouter()
projectEnvManagement.Use(projects.EnvironmentMiddleware)
projectEnvManagement.HandleFunc("/{environment_id}", projects.GetEnvironment).Methods("GET", "HEAD")
projectEnvManagement.HandleFunc("/{environment_id}", projects.UpdateEnvironment).Methods("PUT")
projectEnvManagement.HandleFunc("/{environment_id}", projects.RemoveEnvironment).Methods("DELETE")
@ -173,6 +188,9 @@ func Route() *mux.Router {
projectTmplManagement.HandleFunc("/{template_id}", projects.UpdateTemplate).Methods("PUT")
projectTmplManagement.HandleFunc("/{template_id}", projects.RemoveTemplate).Methods("DELETE")
projectTmplManagement.HandleFunc("/{template_id}", projects.GetTemplate).Methods("GET")
projectTmplManagement.HandleFunc("/{template_id}/tasks", tasks.GetAllTasks).Methods("GET")
projectTmplManagement.HandleFunc("/{template_id}/tasks/last", tasks.GetLastTasks).Methods("GET")
projectTaskManagement := projectUserAPI.PathPrefix("/tasks").Subrouter()
projectTaskManagement.Use(tasks.GetTaskMiddleware)
@ -223,21 +241,33 @@ func debugPrintRoutes(r *mux.Router) {
func servePublic(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
htmlPrefix := ""
if util.Config.OldFrontend {
htmlPrefix = "/html"
}
publicAssetsPrefix := ""
if util.Config.OldFrontend {
publicAssetsPrefix = "public"
}
webPath := "/"
if util.WebHostURL != nil {
webPath = util.WebHostURL.RequestURI()
}
if !strings.HasPrefix(path, webPath+"public") {
if publicAssetsPrefix != "" && !strings.HasPrefix(path, webPath+publicAssetsPrefix) {
if len(strings.Split(path, ".")) > 1 {
w.WriteHeader(http.StatusNotFound)
return
}
path = "/html/index.html"
path = htmlPrefix+"/index.html"
} else if len(strings.Split(path, ".")) == 1 {
path = htmlPrefix+"/index.html"
}
path = strings.Replace(path, webPath+"public/", "", 1)
path = strings.Replace(path, webPath+publicAssetsPrefix+"/", "", 1)
split := strings.Split(path, ".")
suffix := split[len(split)-1]
@ -248,7 +278,7 @@ func servePublic(w http.ResponseWriter, r *http.Request) {
}
// replace base path
if util.WebHostURL != nil && path == "/html/index.html" {
if util.WebHostURL != nil && path == htmlPrefix+"/index.html" {
res = []byte(strings.Replace(string(res),
"<base href=\"/\">",
"<base href=\""+util.WebHostURL.String()+"\">",

View File

@ -57,11 +57,17 @@ func GetTasksList(w http.ResponseWriter, r *http.Request, limit uint64) {
project := context.Get(r, "project").(db.Project)
q := squirrel.Select("task.*, tpl.playbook as tpl_playbook, user.name as user_name, tpl.alias as tpl_alias").
From(taskTypeID).
From("task").
Join("project__template as tpl on task.template_id=tpl.id").
LeftJoin("user on task.user_id=user.id").
Where("tpl.project_id=?", project.ID).
OrderBy("task.created desc")
LeftJoin("user on task.user_id=user.id");
if tpl := context.Get(r, "template"); tpl != nil {
q = q.Where("tpl.project_id=? AND task.template_id=?", project.ID, tpl.(db.Template).ID)
} else {
q = q.Where("tpl.project_id=?", project.ID)
}
q = q.OrderBy("task.created desc, id desc")
if limit > 0 {
q = q.Limit(limit)

View File

@ -101,6 +101,8 @@ type ConfigType struct {
TelegramAlert bool `json:"telegram_alert"`
LdapEnable bool `json:"ldap_enable"`
LdapNeedTLS bool `json:"ldap_needtls"`
OldFrontend bool `json:"old_frontend"`
}
//Config exposes the application configuration storage for use in the application

3
web2/.browserslistrc Normal file
View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

7
web2/.editorconfig Normal file
View File

@ -0,0 +1,7 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100

28
web2/.eslintrc.js Normal file
View File

@ -0,0 +1,28 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/essential',
'@vue/airbnb',
],
parserOptions: {
parser: 'babel-eslint',
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
overrides: [
{
files: [
'**/__tests__/*.{j,t}s?(x)',
'**/tests/unit/**/*.spec.{j,t}s?(x)',
],
env: {
mocha: true,
},
},
],
};

23
web2/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

29
web2/README.md Normal file
View File

@ -0,0 +1,29 @@
# web2
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Run your unit tests
```
npm run test:unit
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
web2/babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
],
};

14489
web2/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
web2/package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "web2",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.18.0",
"core-js": "^3.6.5",
"vue": "^2.6.11",
"vue-router": "^3.2.0",
"vuetify": "^2.2.11"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-unit-mocha": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/eslint-config-airbnb": "^5.0.2",
"@vue/test-utils": "^1.0.3",
"babel-eslint": "^10.1.0",
"chai": "^4.1.2",
"eslint": "^6.7.2",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-vue": "^6.2.2",
"node-sass": "^4.12.0",
"sass": "^1.19.0",
"sass-loader": "^8.0.2",
"vue-cli-plugin-vuetify": "~2.0.7",
"vue-template-compiler": "^2.6.11",
"vuetify-loader": "^1.3.0"
}
}

BIN
web2/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

19
web2/public/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css">
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

701
web2/src/App.vue Normal file
View File

@ -0,0 +1,701 @@
<template>
<v-app v-if="state === 'success'" class="app">
<ItemDialog
v-model="userDialog"
save-button-text="Save"
title="Edit User"
v-if="user"
event-name="i-user"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<UserForm
:project-id="projectId"
:item-id="user.id"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<ItemDialog
v-model="taskLogDialog"
save-button-text="Delete"
:max-width="800"
>
<template v-slot:title={}>
<router-link
class="breadcrumbs__item breadcrumbs__item--link"
:to="`/project/${projectId}/templates/${template ? template.id : null}`"
@click="taskLogDialog = false"
>{{ template ? template.alias : null }}</router-link>
<span class="breadcrumbs__separator">&gt;</span>
<span class="breadcrumbs__item">Task #{{ task ? task.id : null }}</span>
</template>
<template v-slot:form="{}">
<TaskLogView :project-id="projectId" :item-id="task ? task.id : null" />
</template>
</ItemDialog>
<ItemDialog
v-model="newProjectDialog"
save-button-text="Create"
title="New Project"
event-name="i-project"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<ProjectForm
item-id="new"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<v-snackbar
v-model="snackbar"
:color="snackbarColor"
:timeout="3000"
top
>
{{ snackbarText }}
<v-btn
text
@click="snackbar = false"
>
Close
</v-btn>
</v-snackbar>
<v-navigation-drawer
app
dark
fixed
width="260"
v-model="drawer"
mobile-breakpoint="960"
v-if="$route.path.startsWith('/project/')"
>
<v-menu bottom max-width="235" v-if="project">
<template v-slot:activator="{ on, attrs }">
<v-list class="pa-0">
<v-list-item
key="project"
class="app__project-selector"
v-bind="attrs"
v-on="on"
>
<v-list-item-icon>
<v-avatar :color="getProjectColor(project)" size="24">
<span class="white--text">{{ getProjectInitials(project) }}</span>
</v-avatar>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title class="app__project-selector-title">
{{ project.name }}
</v-list-item-title>
</v-list-item-content>
<v-list-item-icon>
<v-icon>mdi-chevron-down</v-icon>
</v-list-item-icon>
</v-list-item>
</v-list>
</template>
<v-list>
<v-list-item
v-for="(item, i) in projects"
:key="i"
:to="`/project/${item.id}`"
@click="selectProject(item.id)"
>
<v-list-item-icon>
<v-avatar :color="getProjectColor(item)" size="24">
<span class="white--text">{{ getProjectInitials(item) }}</span>
</v-avatar>
</v-list-item-icon>
<v-list-item-content>{{ item.name }}</v-list-item-content>
</v-list-item>
<v-list-item @click="newProjectDialog = true">
<v-list-item-icon>
<v-icon>mdi-plus</v-icon>
</v-list-item-icon>
<v-list-item-content>
New project...
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<v-list class="pt-0" v-if="!project">
<v-list-item key="new_project" :to="`/project/new`">
<v-list-item-icon>
<v-icon>mdi-plus</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>New Project</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
<v-list class="pt-0" v-if="project">
<v-list-item key="dashboard" :to="`/project/${projectId}/history`">
<v-list-item-icon>
<v-icon>mdi-view-dashboard</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Dashboard</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item key="templates" :to="`/project/${projectId}/templates`">
<v-list-item-icon>
<v-icon>mdi-check-all</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Task Templates</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item key="inventory" :to="`/project/${projectId}/inventory`">
<v-list-item-icon>
<v-icon>mdi-monitor-multiple</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Inventory</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item key="environment" :to="`/project/${projectId}/environment`">
<v-list-item-icon>
<v-icon>mdi-code-braces</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Environment</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item key="keys" :to="`/project/${projectId}/keys`">
<v-list-item-icon>
<v-icon>mdi-key-change</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Key Store</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item key="repositories" :to="`/project/${projectId}/repositories`">
<v-list-item-icon>
<v-icon>mdi-git</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Playbook Repositories</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item key="team" :to="`/project/${projectId}/team`">
<v-list-item-icon>
<v-icon>mdi-account-multiple</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Team</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
<template v-slot:append>
<v-menu top max-width="235" nudge-top="12">
<template v-slot:activator="{ on, attrs }">
<v-list class="pa-0">
<v-list-item
key="project"
v-bind="attrs"
v-on="on"
>
<v-list-item-icon>
<v-icon>mdi-account</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ user.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<v-list>
<v-list-item key="users" to="/users">
<v-list-item-icon>
<v-icon>mdi-account-multiple</v-icon>
</v-list-item-icon>
<v-list-item-content>
Users
</v-list-item-content>
</v-list-item>
<v-list-item key="edit" @click="userDialog = true">
<v-list-item-icon>
<v-icon>mdi-pencil</v-icon>
</v-list-item-icon>
<v-list-item-content>
Edit Account
</v-list-item-content>
</v-list-item>
<v-list-item key="sign_out" @click="signOut()">
<v-list-item-icon>
<v-icon>mdi-exit-to-app</v-icon>
</v-list-item-icon>
<v-list-item-content>
Sign Out
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-navigation-drawer>
<v-main>
<router-view :projectId="projectId" :userId="user ? user.id : null"></router-view>
</v-main>
</v-app>
<v-app v-else-if="state === 'loading'">
<v-main>
<v-container
fluid
fill-height
align-center
justify-center
class="pa-0"
>
<v-progress-circular
:size="70"
color="primary"
indeterminate
></v-progress-circular>
</v-container>
</v-main>
</v-app>
<v-app v-else></v-app>
</template>
<style lang="scss">
.breadcrumbs {
}
.breadcrumbs__item {
}
.breadcrumbs__item--link {
text-decoration-line: none;
&:hover {
text-decoration-line: underline;
}
}
.breadcrumbs__separator {
padding: 0 10px;
}
.app__project-selector {
height: 64px;
.v-list-item__icon {
margin-top: 20px !important;
}
}
.app__project-selector-title {
font-size: 1.25rem !important;
font-weight: bold;
}
.v-application--is-ltr .v-list-item__action:first-child,
.v-application--is-ltr .v-list-item__icon:first-child {
margin-right: 16px !important;
}
.v-toolbar__content {
height: 64px !important;
}
.v-data-table-header {
}
.theme--light.v-data-table > .v-data-table__wrapper > table > thead > tr:last-child > th {
text-transform: uppercase;
}
.v-data-table > .v-data-table__wrapper > table > tbody > tr {
background: transparent !important;
& > td:first-child {
//font-weight: bold !important;
a {
text-decoration-line: none;
&:hover {
text-decoration-line: underline;
}
}
}
}
.v-data-table > .v-data-table__wrapper > table > tbody > tr > th,
.v-data-table > .v-data-table__wrapper > table > thead > tr > th,
.v-data-table > .v-data-table__wrapper > table > tfoot > tr > th,
.v-data-table > .v-data-table__wrapper > table > tbody > tr > td {
font-size: 16px !important;
}
.v-toolbar__title {
font-weight: bold !important;
}
.v-app-bar__nav-icon {
margin-left: 0 !important;
}
.v-toolbar__title:not(:first-child) {
margin-left: 10px !important;
}
@media (min-width: 960px) {
.v-app-bar__nav-icon {
display: none !important;
}
.v-toolbar__title:not(:first-child) {
padding-left: 0 !important;
margin-left: 0 !important;
}
}
</style>
<script>
import axios from 'axios';
import { getErrorMessage } from '@/lib/error';
import ItemDialog from '@/components/ItemDialog.vue';
import TaskLogView from '@/components/TaskLogView.vue';
import ProjectForm from '@/components/ProjectForm.vue';
import UserForm from '@/components/UserForm.vue';
import EventBus from './event-bus';
const PROJECT_COLORS = [
'red',
'blue',
'orange',
'green',
];
export default {
name: 'App',
components: {
UserForm,
ItemDialog,
TaskLogView,
ProjectForm,
},
data() {
return {
drawer: null,
user: null,
state: 'loading',
snackbar: false,
snackbarText: '',
snackbarColor: '',
projects: null,
newProjectDialog: null,
userDialog: null,
taskLogDialog: null,
task: null,
template: null,
};
},
watch: {
async projects(val) {
if (val.length === 0
&& this.$route.path.startsWith('/project/')
&& this.$route.path !== '/project/new') {
await this.$router.push({ path: '/project/new' });
}
},
},
computed: {
projectId() {
return parseInt(this.$route.params.projectId, 10) || null;
},
project() {
return this.projects.find((x) => x.id === this.projectId);
},
isAuthenticated() {
return document.cookie.includes('semaphore=');
},
},
async created() {
if (!this.isAuthenticated) {
if (this.$route.path !== '/auth/login') {
await this.$router.push({ path: '/auth/login' });
}
this.state = 'success';
return;
}
try {
await this.init();
this.state = 'success';
} catch (err) { // notify about problem and sign out
EventBus.$emit('i-snackbar', {
color: 'error',
text: getErrorMessage(err),
});
// EventBus.$emit('i-session-end');
console.error(err);
}
},
mounted() {
EventBus.$on('i-snackbar', (e) => {
this.snackbar = true;
this.snackbarColor = e.color;
this.snackbarText = e.text;
});
EventBus.$on('i-session-end', async () => {
await this.signOut();
});
EventBus.$on('i-session-create', async () => {
await this.init();
await this.trySelectMostSuitableProject();
});
EventBus.$on('i-account-change', async () => {
await this.loadUserInfo();
});
EventBus.$on('i-show-drawer', async () => {
this.drawer = true;
});
EventBus.$on('i-show-task', async (e) => {
this.task = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/tasks/${e.taskId}`,
responseType: 'json',
})).data;
this.template = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/templates/${this.task.template_id}`,
responseType: 'json',
})).data;
this.taskLogDialog = true;
});
EventBus.$on('i-open-last-project', async () => {
await this.trySelectMostSuitableProject();
});
EventBus.$on('i-user', async (e) => {
let text;
switch (e.action) {
case 'new':
text = `User ${e.item.name} created`;
break;
case 'edit':
text = `User ${e.item.name} saved`;
break;
case 'delete':
text = `User ${e.item.name} deleted`;
break;
default:
throw new Error('Unknown project action');
}
EventBus.$emit('i-snackbar', {
color: 'success',
text,
});
if (this.user && e.item.id === this.user.id) {
await this.loadUserInfo();
}
});
EventBus.$on('i-project', async (e) => {
let text;
const project = this.projects.find((p) => p.id === e.item.id) || e.item;
const projectName = project.name || `#${project.id}`;
switch (e.action) {
case 'new':
text = `Project ${projectName} created`;
break;
case 'edit':
text = `Project ${projectName} saved`;
break;
case 'delete':
text = `Project ${projectName} deleted`;
break;
default:
throw new Error('Unknown project action');
}
EventBus.$emit('i-snackbar', {
color: 'success',
text,
});
await this.loadProjects();
switch (e.action) {
case 'new':
await this.selectProject(e.item.id);
break;
case 'delete':
if (this.projectId === e.item.id && this.projects.length > 0) {
await this.selectProject(this.projects[0].id);
}
break;
default:
break;
}
});
},
methods: {
async init() {
await this.loadUserInfo();
await this.loadProjects();
if (this.$route.path === '/'
|| this.$route.path === '/project'
|| (this.$route.path.startsWith('/project/'))) {
// try to find project and switch to it
await this.trySelectMostSuitableProject();
}
if (this.$route.query.t) {
EventBus.$emit('i-show-task', {
itemId: parseInt(this.$route.query.t || '', 10),
});
}
},
async trySelectMostSuitableProject() {
if (this.projects.length === 0) {
if (this.$route.path !== '/project/new') {
await this.$router.push({ path: '/project/new' });
}
return;
}
let projectId;
if (this.projectId) {
projectId = this.projectId;
}
if ((projectId == null || !this.projects.some((p) => p.id === projectId))
&& localStorage.getItem('projectId')) {
projectId = parseInt(localStorage.getItem('projectId'), 10);
}
if (projectId == null || !this.projects.some((p) => p.id === projectId)) {
projectId = this.projects[0].id;
}
if (projectId != null) {
await this.selectProject(projectId);
}
},
async selectProject(projectId) {
localStorage.setItem('projectId', projectId);
if (this.projectId === projectId) {
return;
}
await this.$router.push({ path: `/project/${projectId}` });
},
async loadProjects() {
this.projects = (await axios({
method: 'get',
url: '/api/projects',
responseType: 'json',
})).data;
},
async loadUserInfo() {
if (!this.isAuthenticated) {
return;
}
this.user = (await axios({
method: 'get',
url: '/api/user',
responseType: 'json',
})).data;
},
getProjectColor(projectData) {
const projectIndex = this.projects.length
- this.projects.findIndex((p) => p.id === projectData.id);
return PROJECT_COLORS[projectIndex % PROJECT_COLORS.length];
},
getProjectInitials(projectData) {
const parts = projectData.name.split(/\s/);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return parts[0].substr(0, 2).toUpperCase();
},
async signOut() {
this.snackbar = false;
this.snackbarColor = '';
this.snackbarText = '';
(await axios({
method: 'post',
url: '/api/auth/logout',
responseType: 'json',
}));
if (this.$route.path !== '/auth/login') {
await this.$router.push({ path: '/auth/login' });
}
},
},
};
</script>

BIN
web2/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

1
web2/src/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.5 100"><defs><style>.cls-1{fill:#1697f6;}.cls-2{fill:#7bc6ff;}.cls-3{fill:#1867c0;}.cls-4{fill:#aeddff;}</style></defs><title>Artboard 46</title><polyline class="cls-1" points="43.75 0 23.31 0 43.75 48.32"/><polygon class="cls-2" points="43.75 62.5 43.75 100 0 14.58 22.92 14.58 43.75 62.5"/><polyline class="cls-3" points="43.75 0 64.19 0 43.75 48.32"/><polygon class="cls-4" points="64.58 14.58 87.5 14.58 43.75 100 43.75 62.5 64.58 14.58"/></svg>

After

Width:  |  Height:  |  Size: 539 B

View File

@ -0,0 +1,51 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="item != null"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}</v-alert>
<v-text-field
v-model="item.name"
label="Environment Name"
:rules="[v => !!v || 'Name is required']"
required
:disabled="formSaving"
class="mb-4"
></v-text-field>
<v-textarea
v-model="item.json"
label="Environment (This has to be a JSON object)"
:disabled="formSaving"
solo
></v-textarea>
<div>
Must be valid JSON. You may use the key ENV to pass a json object which sets environmental
variables for the ansible command execution environment
</div>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
export default {
mixins: [ItemFormBase],
methods: {
getItemsUrl() {
return `/api/project/${this.projectId}/environment`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/environment/${this.itemId}`;
},
},
};
</script>

View File

@ -0,0 +1,99 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="item != null && keys != null"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}</v-alert>
<v-text-field
v-model="item.name"
label="Name"
:rules="[v => !!v || 'Name is required']"
required
:disabled="formSaving"
></v-text-field>
<v-select
v-model="item.ssh_key_id"
label="SSH Key"
:items="keys"
item-value="id"
item-text="name"
:rules="[v => !!v || 'SSH Key is required']"
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.type"
label="Type"
:rules="[v => !!v || 'Type is required']"
:items="inventoryTypes"
item-value="id"
item-text="name"
required
:disabled="formSaving"
></v-select>
<v-text-field
v-model="item.inventory"
label="Path to inventory file"
:rules="[v => !!v || 'Path to inventory file is required']"
required
:disabled="formSaving"
v-if="item.type === 'file'"
></v-text-field>
<v-textarea
v-model="item.inventory"
label="Inventory"
:rules="[v => !!v || 'Inventory is required']"
required
:disabled="formSaving"
v-if="item.type === 'static'"
solo
></v-textarea>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
import axios from 'axios';
export default {
mixins: [ItemFormBase],
data() {
return {
keys: null,
inventoryTypes: [{
id: 'static',
name: 'Static',
}, {
id: 'file',
name: 'File',
}],
};
},
async created() {
this.keys = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/keys`,
responseType: 'json',
})).data;
},
methods: {
getItemsUrl() {
return `/api/project/${this.projectId}/inventory`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/inventory/${this.itemId}`;
},
},
};
</script>

View File

@ -0,0 +1,106 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<v-dialog
v-model="dialog"
:max-width="maxWidth || 400"
persistent
:transition="false"
:content-class="'item-dialog item-dialog--' + position"
>
<v-card>
<v-card-title class="headline">
<slot
name="title"
>{{ title }}</slot>
</v-card-title>
<v-card-text>
<slot
name="form"
:onSave="close"
:onError="clearFlags"
:needSave="needSave"
:needReset="needReset"
></slot>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="blue darken-1"
text
@click="close()"
>
Cancel
</v-btn>
<v-btn
color="blue darken-1"
text
@click="needSave = true"
>
{{ saveButtonText }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style lang="scss">
.item-dialog--top {
align-self: flex-start;
}
.item-dialog--center {
}
</style>
<script>
import EventBus from '@/event-bus';
export default {
props: {
position: String,
title: String,
saveButtonText: String,
value: Boolean,
maxWidth: Number,
eventName: String,
},
data() {
return {
dialog: false,
needSave: false,
needReset: false,
};
},
watch: {
async dialog(val) {
this.$emit('input', val);
this.needReset = val;
},
async value(val) {
this.dialog = val;
},
},
methods: {
close(e) {
this.dialog = false;
this.clearFlags();
if (e) {
this.$emit('save', e);
if (this.eventName) {
EventBus.$emit(this.eventName, e);
}
}
},
clearFlags() {
this.needSave = false;
this.needReset = false;
},
},
};
</script>

View File

@ -0,0 +1,110 @@
import axios from 'axios';
import { getErrorMessage } from '@/lib/error';
export default {
props: {
itemId: [Number, String],
projectId: [Number, String],
needSave: Boolean,
needReset: Boolean,
},
data() {
return {
item: null,
formValid: false,
formError: null,
formSaving: false,
};
},
async created() {
await this.loadData();
},
computed: {
isNew() {
return this.itemId === 'new';
},
},
watch: {
async needSave(val) {
if (val) {
await this.save();
}
},
async needReset(val) {
if (val) {
await this.reset();
}
},
},
methods: {
async reset() {
this.item = null;
if (this.$refs.form) {
this.$refs.form.resetValidation();
}
await this.loadData();
},
getItemsUrl() {
throw new Error('Not implemented');
},
getSingleItemUrl() {
throw new Error('Not implemented');
},
async loadData() {
if (this.isNew) {
this.item = {};
} else {
this.item = (await axios({
method: 'get',
url: this.getSingleItemUrl(),
responseType: 'json',
})).data;
}
},
/**
* Saves or creates item via API.
* @returns {Promise<null>} null if validation didn't pass or user data if user saved.
*/
async save() {
this.formError = null;
if (!this.$refs.form.validate()) {
return null;
}
this.formSaving = true;
let item;
try {
item = (await axios({
method: this.isNew ? 'post' : 'put',
url: this.isNew
? this.getItemsUrl()
: this.getSingleItemUrl(),
responseType: 'json',
data: this.item,
})).data;
this.$emit('save', {
item: item || this.item,
action: this.isNew ? 'new' : 'edit',
});
} catch (err) {
this.formError = getErrorMessage(err);
} finally {
this.formSaving = false;
}
return item || this.item;
},
},
};

View File

@ -0,0 +1,100 @@
import axios from 'axios';
import EventBus from '@/event-bus';
import InventoryForm from '@/components/InventoryForm.vue';
import ItemDialog from '@/components/ItemDialog.vue';
import YesNoDialog from '@/components/YesNoDialog.vue';
import { getErrorMessage } from '@/lib/error';
/**
*
*/
export default {
components: {
YesNoDialog,
ItemDialog,
InventoryForm,
},
props: {
projectId: Number,
userId: Number,
},
data() {
return {
headers: this.getHeaders(),
items: null,
itemId: null,
editDialog: null,
deleteItemDialog: null,
};
},
async created() {
await this.loadItems();
},
methods: {
getSingleItemUrl() {
throw new Error('Not implemented');
},
getHeaders() {
throw new Error('Not implemented');
},
getEventName() {
throw new Error('Not implemented');
},
showDrawer() {
EventBus.$emit('i-show-drawer');
},
async onItemSave() {
await this.loadItems();
},
askDeleteItem(itemId) {
this.itemId = itemId;
this.deleteItemDialog = true;
},
async deleteItem(itemId) {
try {
const item = this.items.find((x) => x.id === itemId);
await axios({
method: 'delete',
url: this.getSingleItemUrl(),
responseType: 'json',
});
EventBus.$emit(this.getEventName(), {
action: 'delete',
item,
});
await this.loadItems();
} catch (err) {
EventBus.$emit('i-snackbar', {
color: 'error',
text: getErrorMessage(err),
});
}
},
editItem(itemId) {
this.itemId = itemId;
this.editDialog = true;
},
async loadItems() {
this.items = (await axios({
method: 'get',
url: this.getItemsUrl(),
responseType: 'json',
})).data;
},
},
};

View File

@ -0,0 +1,81 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="item != null"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}</v-alert>
<v-text-field
v-model="item.name"
label="Key Name"
:rules="[v => !!v || 'Name is required']"
required
:disabled="formSaving"
></v-text-field>
<v-select
v-model="item.type"
label="Type"
:rules="[v => !!v || 'Type is required']"
:items="inventoryTypes"
item-value="id"
item-text="name"
required
:disabled="formSaving"
></v-select>
<v-textarea
v-model="item.key"
label="Public Key"
:disabled="formSaving"
v-if="item.type === 'ssh'"
></v-textarea>
<v-textarea
v-model="item.secret"
label="Private Key"
:disabled="formSaving"
v-if="item.type === 'ssh'"
></v-textarea>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
export default {
mixins: [ItemFormBase],
data() {
return {
keys: null,
inventoryTypes: [{
id: 'ssh',
name: 'SSH Key',
}, {
id: 'aws',
name: 'AWS IAM credentials',
}, {
id: 'gcloud',
name: 'Google Cloud API Key',
}, {
id: 'do',
name: 'DigitalOcean API Key',
}],
};
},
methods: {
getItemsUrl() {
return `/api/project/${this.projectId}/keys`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/keys/${this.itemId}`;
},
},
};
</script>

View File

@ -0,0 +1,48 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="item != null"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}</v-alert>
<v-text-field
v-model="item.name"
label="Playbook Alias"
:rules="[v => !!v || 'Project name is required']"
required
:disabled="formSaving"
></v-text-field>
<v-checkbox
v-model="item.alert"
label="Allow alerts for this project"
></v-checkbox>
<v-text-field
v-model="item.alert_chat"
label="Chat ID"
:disabled="formSaving"
></v-text-field>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
export default {
mixins: [ItemFormBase],
methods: {
getItemsUrl() {
return '/api/projects';
},
getSingleItemUrl() {
return `/api/project/${this.itemId}`;
},
},
};
</script>

View File

@ -0,0 +1,77 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="item != null && keys != null"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}</v-alert>
<v-text-field
v-model="item.name"
label="Name"
:rules="[v => !!v || 'Name is required']"
required
:disabled="formSaving"
></v-text-field>
<v-text-field
v-model="item.git_url"
label="Git URL"
:rules="[v => !!v || 'Repository is required']"
required
:disabled="formSaving"
></v-text-field>
<v-select
v-model="item.ssh_key_id"
label="SSH Key"
:items="keys"
item-value="id"
item-text="name"
:rules="[v => !!v || 'SSH Key is required']"
required
:disabled="formSaving"
></v-select>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
import axios from 'axios';
export default {
mixins: [ItemFormBase],
data() {
return {
keys: null,
inventoryTypes: [{
id: 'static',
name: 'Static',
}, {
id: 'file',
name: 'File',
}],
};
},
async created() {
this.keys = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/keys`,
responseType: 'json',
})).data;
},
methods: {
getItemsUrl() {
return `/api/project/${this.projectId}/repositories`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/repositories/${this.itemId}`;
},
},
};
</script>

View File

@ -0,0 +1,52 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="item != null"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}</v-alert>
<v-text-field
v-model="item.playbook"
label="Playbook Override"
:disabled="formSaving"
></v-text-field>
<v-textarea
v-model="item.environment"
label="Environment Override (*MUST* be valid JSON)"
:disabled="formSaving"
rows="4"
></v-textarea>
<v-textarea
v-model="item.arguments"
label="Extra CLI Arguments"
:disabled="formSaving"
rows="4"
></v-textarea>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
export default {
mixins: [ItemFormBase],
props: {
templateId: Number,
},
created() {
this.item.template_id = this.templateId;
},
methods: {
getItemsUrl() {
return `/api/project/${this.projectId}/tasks`;
},
},
};
</script>

View File

@ -0,0 +1,120 @@
<template>
<div v-if="item != null && output != null && user != null">
<v-container class="pa-0">
<v-row no-gutters>
<v-col>
<v-list two-line subheader class="pa-0">
<v-list-item class="pa-0">
<v-list-item-content>
<v-list-item-title>Author</v-list-item-title>
<v-list-item-subtitle>{{ user.name }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item class="pa-0">
<v-list-item-content>
<v-list-item-title>Status</v-list-item-title>
<v-list-item-subtitle>{{ item.status }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-col>
<v-col>
<v-list two-line subheader class="pa-0">
<v-list-item class="pa-0">
<v-list-item-content>
<v-list-item-title>Created</v-list-item-title>
<v-list-item-subtitle>{{ item.created }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item class="pa-0">
<v-list-item-content>
<v-list-item-title>Started</v-list-item-title>
<v-list-item-subtitle>{{ item.start || '&mdash;' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item class="pa-0">
<v-list-item-content>
<v-list-item-title>Ended</v-list-item-title>
<v-list-item-subtitle>{{ item.end || '&mdash;' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-col>
</v-row>
</v-container>
<div class="task-log-view">
<div class="task-log-view__record" v-for="record in output" :key="record.id">
<div class="task-log-view__time">{{ record.time }}</div>
<div class="task-log-view__output">{{ record.output }}</div>
</div>
</div>
</div>
</template>
<style lang="scss">
.task-log-view {
height: 400px;
overflow: auto;
border: 1px solid gray;
border-radius: 4px;
font-family: monospace;
}
.task-log-view__record {
display: flex;
flex-direction: row;
justify-content: left;
}
.task-log-view__time {
width: 250px;
}
.task-log-view__output {
width: calc(100% - 250px);
}
</style>
<script>
import axios from 'axios';
export default {
props: {
itemId: Number,
projectId: Number,
},
data() {
return {
item: null,
output: null,
user: null,
};
},
async created() {
await this.loadData();
},
methods: {
async loadData() {
this.item = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/tasks/${this.itemId}`,
responseType: 'json',
})).data;
this.output = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/tasks/${this.itemId}/output`,
responseType: 'json',
})).data;
this.user = (await axios({
method: 'get',
url: `/api/users/${this.item.user_id}`,
responseType: 'json',
})).data;
},
},
};
</script>

View File

@ -0,0 +1,63 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="users != null"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}</v-alert>
<v-select
v-model="item.user_id"
label="User"
:items="users"
item-value="id"
item-text="name"
:rules="[v => !!v || 'User is required']"
required
:disabled="formSaving"
></v-select>
<v-checkbox
v-model="item.admin"
label="Administrator"
></v-checkbox>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
import axios from 'axios';
export default {
mixins: [ItemFormBase],
data() {
return {
users: null,
userId: null,
};
},
async created() {
this.users = (await axios({
method: 'get',
url: '/api/users',
responseType: 'json',
})).data;
},
methods: {
getItemsUrl() {
return `/api/project/${this.projectId}/users`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/users/${this.itemId}`;
},
},
};
</script>

View File

@ -0,0 +1,128 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="isLoaded"
>
<v-text-field
v-model="item.alias"
label="Playbook Alias"
:rules="[v => !!v || 'Playbook Alias is required']"
required
:disabled="formSaving"
></v-text-field>
<v-text-field
v-model="item.playbook"
label="Playbook Name"
:rules="[v => !!v || 'Playbook Name is required']"
required
:disabled="formSaving"
></v-text-field>
<v-select
v-model="item.ssh_key_id"
label="SSH Key"
:items="keys"
item-value="id"
item-text="name"
:rules="[v => !!v || 'SSH Key is required']"
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.inventory_id"
label="Inventory"
:items="inventory"
item-value="id"
item-text="name"
:rules="[v => !!v || 'Inventory is required']"
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.repository_id"
label="Playbook Repository"
:items="repositories"
item-value="id"
item-text="name"
:rules="[v => !!v || 'Playbook Repository is required']"
required
:disabled="formSaving"
></v-select>
<v-select
v-model="item.environment_id"
label="Environment"
:items="environment"
item-value="id"
item-text="name"
:rules="[v => !!v || 'Environment is required']"
required
:disabled="formSaving"
></v-select>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
import axios from 'axios';
export default {
mixins: [ItemFormBase],
data() {
return {
item: null,
keys: null,
inventory: null,
repositories: null,
environment: null,
};
},
async created() {
this.keys = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/keys`,
responseType: 'json',
})).data;
this.repositories = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/repositories`,
responseType: 'json',
})).data;
this.inventory = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/inventory`,
responseType: 'json',
})).data;
this.environment = (await axios({
keys: 'get',
url: `/api/project/${this.projectId}/environment`,
responseType: 'json',
})).data;
},
computed: {
isLoaded() {
if (this.isNew) {
return true;
}
return this.keys && this.repositories && this.inventory && this.environment && this.item;
},
},
methods: {
getItemsUrl() {
return `/api/project/${this.projectId}/templates`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/templates/${this.itemId}`;
},
},
};
</script>

View File

@ -0,0 +1,64 @@
<template>
<v-form
ref="form"
lazy-validation
v-model="formValid"
v-if="item != null"
>
<v-alert
:value="formError"
color="error"
class="pb-2"
>{{ formError }}</v-alert>
<v-text-field
v-model="item.name"
label="Name"
:rules="[v => !!v || 'Name is required']"
required
:disabled="formSaving"
></v-text-field>
<v-text-field
v-model="item.username"
label="Username"
:rules="[v => !!v || 'Username is required']"
required
:disabled="formSaving"
></v-text-field>
<v-text-field
v-model="item.email"
label="Email"
:rules="[v => !!v || 'Email is required']"
required
:disabled="formSaving"
></v-text-field>
<v-checkbox
v-model="item.admin"
label="Admin user"
></v-checkbox>
<v-checkbox
v-model="item.alert"
label="Send alerts"
></v-checkbox>
</v-form>
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
export default {
mixins: [ItemFormBase],
methods: {
getItemsUrl() {
return '/api/users';
},
getSingleItemUrl() {
return `/api/users/${this.itemId}`;
},
},
};
</script>

View File

@ -0,0 +1,71 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<v-dialog
v-model="dialog"
max-width="290"
>
<v-card>
<v-card-title class="headline">{{ title }}</v-card-title>
<v-card-text>{{ text }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="blue darken-1"
text
@click="no()"
>
{{ noButtonTitle || 'Cancel' }}
</v-btn>
<v-btn
color="blue darken-1"
text
@click="yes()"
>
{{ yesButtonTitle || 'Yes' }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
props: {
value: Boolean,
title: String,
text: String,
yesButtonTitle: String,
noButtonTitle: String,
},
data() {
return {
dialog: false,
};
},
watch: {
async dialog(val) {
this.$emit('input', val);
},
async value(val) {
this.dialog = val;
},
},
methods: {
async yes() {
this.$emit('yes');
this.dialog = false;
},
async no() {
this.$emit('no');
this.dialog = false;
},
},
};
</script>

3
web2/src/event-bus.js Normal file
View File

@ -0,0 +1,3 @@
import Vue from 'vue';
export default new Vue();

3
web2/src/lib/delay.js Normal file
View File

@ -0,0 +1,3 @@
export default function delay(milliseconds = 100) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}

7
web2/src/lib/error.js Normal file
View File

@ -0,0 +1,7 @@
// eslint-disable-next-line import/prefer-default-export
export function getErrorMessage(err) {
if (err.response && err.response.data) {
return err.response.data.error || err.message;
}
return err.message;
}

12
web2/src/main.js Normal file
View File

@ -0,0 +1,12 @@
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import vuetify from './plugins/vuetify';
Vue.config.productionTip = false;
new Vue({
router,
vuetify,
render: (h) => h(App),
}).$mount('#app');

View File

@ -0,0 +1,7 @@
import Vue from 'vue';
import Vuetify from 'vuetify/lib';
Vue.use(Vuetify);
export default new Vuetify({
});

89
web2/src/router/index.js Normal file
View File

@ -0,0 +1,89 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import History from '../views/project/History.vue';
import Activity from '../views/project/Activity.vue';
import Settings from '../views/project/Settings.vue';
import Templates from '../views/project/Templates.vue';
import TemplateView from '../views/project/TemplateView.vue';
import Environment from '../views/project/Environment.vue';
import Inventory from '../views/project/Inventory.vue';
import Keys from '../views/project/Keys.vue';
import Repositories from '../views/project/Repositories.vue';
import Team from '../views/project/Team.vue';
import Users from '../views/Users.vue';
import Auth from '../views/Auth.vue';
import New from '../views/project/New.vue';
import ChangePassword from '../views/ChangePassword.vue';
Vue.use(VueRouter);
const routes = [
{
path: '/project/new',
component: New,
},
{
path: '/project/:projectId',
redirect: '/project/:projectId/history',
},
{
path: '/project/:projectId/history',
component: History,
},
{
path: '/project/:projectId/activity',
component: Activity,
},
{
path: '/project/:projectId/settings',
component: Settings,
},
{
path: '/project/:projectId/templates',
component: Templates,
},
{
path: '/project/:projectId/templates/:templateId',
component: TemplateView,
},
{
path: '/project/:projectId/environment',
component: Environment,
},
{
path: '/project/:projectId/inventory',
component: Inventory,
},
{
path: '/project/:projectId/repositories',
component: Repositories,
},
{
path: '/project/:projectId/keys',
component: Keys,
},
{
path: '/project/:projectId/team',
component: Team,
},
{
path: '/auth/login',
component: Auth,
},
{
path: '/users',
component: Users,
},
{
path: '/change-password',
component: ChangePassword,
},
];
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes,
});
export default router;

232
web2/src/views/Auth.vue Normal file
View File

@ -0,0 +1,232 @@
<template>
<div class="auth">
<v-dialog
v-model="forgotPasswordDialog"
max-width="290"
persistent
>
<v-card>
<v-card-title class="headline">Forgot password?</v-card-title>
<v-alert
:value="forgotPasswordSubmitted"
color="success"
>
Check your inbox.
</v-alert>
<v-alert
:value="forgotPasswordError"
color="error"
>
{{ forgotPasswordError }}
</v-alert>
<v-card-text>
<v-form
ref="forgotPasswordForm"
lazy-validation
v-model="forgotPasswordFormValid"
>
<v-text-field
v-model="email"
label="Email"
:rules="emailRules"
required
:disabled="forgotPasswordSubmitting"
></v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="green darken-1"
text
:disabled="forgotPasswordSubmitting"
@click="forgotPasswordDialog = false"
>
Cancel
</v-btn>
<v-btn
color="green darken-1"
text
:disabled="forgotPasswordSubmitting"
@click="submitForgotPassword()"
>
Reset Password
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-container
fluid
fill-height
align-center
justify-center
class="pa-0"
>
<v-form
ref="signInForm"
lazy-validation
v-model="signInFormValid"
style="width: 300px; height: 300px;"
>
<h3 class="text-center mb-8">SEMAPHORE</h3>
<v-alert
:value="signInError"
color="error"
style="margin-bottom: 20px;"
>{{ signInError }}</v-alert>
<v-text-field
v-model="username"
label="Username"
:rules="usernameRules"
autofocus
required
:disabled="signInProcess"
></v-text-field>
<v-text-field
v-model="password"
label="Password"
:rules="[v => !!v || 'Password is required']"
type="password"
required
:disabled="signInProcess"
@keyup.enter.native="signIn"
style="margin-bottom: 20px;"
></v-text-field>
<v-btn
color="primary"
@click="signIn"
:disabled="signInProcess"
block
>
Sign In
</v-btn>
</v-form>
</v-container>
</div>
</template>
<style lang="scss">
.auth {
height: 100vh;
}
</style>
<script>
import axios from 'axios';
import { getErrorMessage } from '@/lib/error';
import EventBus from '@/event-bus';
export default {
data() {
return {
signInFormValid: false,
signInError: null,
signInProcess: false,
password: '',
username: '',
forgotPasswordFormValid: false,
forgotPasswordError: false,
forgotPasswordSubmitted: false,
forgotPasswordSubmitting: false,
forgotPasswordDialog: false,
email: '',
newPassword: '',
newPassword2: '',
emailRules: [
(v) => !!v || 'Email is required',
],
passwordRules: [
(v) => !!v || 'Password is required',
(v) => v.length >= 6 || 'Password too short. Min 6 characters',
],
usernameRules: [
(v) => !!v || 'Username is required',
],
};
},
async created() {
if (this.isAuthenticated()) {
EventBus.$emit('i-session-create');
}
},
methods: {
isAuthenticated() {
return document.cookie.includes('semaphore=');
},
async submitForgotPassword() {
this.forgotPasswordSubmitted = false;
this.forgotPasswordError = null;
if (!this.$refs.forgotPasswordForm.validate()) {
return;
}
this.forgotPasswordSubmitting = true;
try {
await axios({
method: 'post',
url: '/v1/session/forgot-password',
data: {
email: this.email,
},
});
this.forgotPasswordSubmitted = true;
} catch (err) {
this.forgotPasswordError = err.response.data.error;
} finally {
this.forgotPasswordSubmitting = false;
}
},
async signIn() {
this.signInError = null;
if (!this.$refs.signInForm.validate()) {
return;
}
this.signInProcess = true;
try {
await axios({
method: 'post',
url: '/api/auth/login',
responseType: 'json',
data: {
auth: this.username,
password: this.password,
},
});
EventBus.$emit('i-session-create');
} catch (err) {
this.signInError = getErrorMessage(err);
} finally {
this.signInProcess = false;
}
},
forgotPassword() {
this.forgotPasswordError = null;
this.forgotPasswordSubmitted = false;
this.email = '';
this.$refs.forgotPasswordForm.resetValidation();
this.forgotPasswordDialog = true;
},
},
};
</script>

View File

@ -0,0 +1,112 @@
<template>
<v-container
fluid
fill-height
align-center
justify-center
class="pa-0"
>
<v-form
ref="changePasswordForm"
lazy-validation
v-model="changePasswordFormValid"
style="width: 300px;"
>
<v-alert
:value="changePasswordError"
color="error"
style="margin-bottom: 20px;"
>{{ changePasswordError }}</v-alert>
<v-text-field
v-model="newPassword"
label="Password"
:rules="passwordRules"
type="password"
counter
required
></v-text-field>
<v-text-field
v-model="newPassword2"
label="Repeat password"
type="password"
required
counter
style="margin-bottom: 20px;"
></v-text-field>
<div class="text-xs-right">
<v-btn
color="primary"
@click="changePassword"
style="margin-right: 0;"
align-end
>
Change password
</v-btn>
</div>
</v-form>
</v-container>
</template>
<script>
import axios from 'axios';
import EventBus from '@/event-bus';
import { getErrorMessage } from '@/lib/error';
export default {
data() {
return {
changePasswordFormValid: false,
changePasswordError: null,
changePasswordInProgress: false,
newPassword: '',
newPassword2: '',
passwordRules: [
(v) => !!v || 'Password is required',
(v) => v.length >= 6 || 'Password too short. Min 6 characters',
],
};
},
methods: {
async changePassword() {
this.changePasswordError = null;
if (!this.$refs.changePasswordForm.validate()) {
return;
}
if (this.newPassword !== this.newPassword2) {
this.changePasswordError = 'Passwords not equal';
return;
}
this.changePasswordInProgress = true;
try {
await axios({
method: 'post',
url: '/v1/session/change-password',
data: {
token: this.$route.query.token,
password: this.newPassword,
},
});
await this.$router.replace('/');
EventBus.$emit('i-snackbar', {
color: 'success',
text: 'Password changed',
});
} catch (err) {
this.changePasswordError = getErrorMessage(err);
} finally {
this.changePasswordInProgress = false;
}
},
},
};
</script>

140
web2/src/views/Users.vue Normal file
View File

@ -0,0 +1,140 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="items != null">
<ItemDialog
v-model="editDialog"
save-button-text="Save"
title="Edit User"
@save="loadItems()"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<UserForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<YesNoDialog
title="Delete user"
text="Are you really want to delete this user?"
v-model="deleteItemDialog"
@yes="deleteItem(itemId)"
/>
<v-toolbar flat color="white">
<v-btn
icon
class="mr-4"
@click="returnToProjects()"
>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-toolbar-title>Users</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="editItem('new')"
>New User</v-btn>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="mt-4"
:items-per-page="Number.MAX_VALUE"
>
<template v-slot:item.actions="{ item }">
<div style="white-space: nowrap">
<v-btn
icon
class="mr-1"
@click="askDeleteItem(item.id)"
:disabled="item.id === userId"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn
icon
class="mr-1"
@click="editItem(item.id)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</div>
</template>
</v-data-table>
</div>
</template>
<script>
import EventBus from '@/event-bus';
import YesNoDialog from '@/components/YesNoDialog.vue';
import ItemListPageBase from '@/components/ItemListPageBase';
import ItemDialog from '@/components/ItemDialog.vue';
import UserForm from '@/components/UserForm.vue';
export default {
mixins: [ItemListPageBase],
components: {
YesNoDialog,
UserForm,
ItemDialog,
},
methods: {
getHeaders() {
return [{
text: 'Name',
value: 'name',
},
{
text: 'Username',
value: 'username',
},
{
text: 'Email',
value: 'email',
},
{
text: 'Alert',
value: 'alert',
},
{
text: 'Admin',
value: 'admin',
},
{
text: 'External',
value: 'external',
},
{
text: 'Actions',
value: 'actions',
sortable: false,
}];
},
async returnToProjects() {
EventBus.$emit('i-open-last-project');
},
getItemsUrl() {
return '/api/users';
},
getSingleItemUrl() {
return `/api/users/${this.itemId}`;
},
getEventName() {
return 'i-user';
},
},
};
</script>

View File

@ -0,0 +1,52 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="items">
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>Dashboard</v-toolbar-title>
<v-spacer></v-spacer>
<div>
<v-tabs centered>
<v-tab key="history" :to="`/project/${projectId}/history`">History</v-tab>
<v-tab key="activity" :to="`/project/${projectId}/activity`">Activity</v-tab>
<v-tab key="settings" :to="`/project/${projectId}/settings`">Settings</v-tab>
</v-tabs>
</div>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="mt-4"
>
</v-data-table>
</div>
</template>
<script>
import ItemListPageBase from '@/components/ItemListPageBase';
export default {
mixins: [ItemListPageBase],
methods: {
getHeaders() {
return [
{
text: 'Time',
value: 'created',
sortable: false,
},
{
text: 'Description',
value: 'description',
sortable: false,
},
];
},
getItemsUrl() {
return `/api/project/${this.projectId}/events/last`;
},
},
};
</script>

View File

@ -0,0 +1,113 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="items != null">
<ItemDialog
v-model="editDialog"
save-button-text="Save"
title="Edit Environment"
:max-width="500"
@save="loadItems"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<EnvironmentForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<YesNoDialog
title="Delete environment"
text="Are you really want to delete this environment?"
v-model="deleteItemDialog"
@yes="deleteItem(itemId)"
/>
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>Environment</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="editItem('new')"
>New Environment</v-btn>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="mt-4"
:items-per-page="Number.MAX_VALUE"
>
<template v-slot:item.actions="{ item }">
<div style="white-space: nowrap">
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn
icon
class="mr-1"
v-bind="attrs"
v-on="on"
@click="askDeleteItem(item.id)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<span>Delete environment</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn
icon
class="mr-1"
v-bind="attrs"
v-on="on"
@click="editItem(item.id)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</template>
<span>Edit environment</span>
</v-tooltip>
</div>
</template>
</v-data-table>
</div>
</template>
<script>
import ItemListPageBase from '@/components/ItemListPageBase';
import EnvironmentForm from '@/components/EnvironmentForm.vue';
export default {
components: { EnvironmentForm },
mixins: [ItemListPageBase],
methods: {
getHeaders() {
return [{
text: 'Name',
value: 'name',
},
{
text: 'Actions',
value: 'actions',
sortable: false,
}];
},
getItemsUrl() {
return `/api/project/${this.projectId}/environment`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/environment/${this.itemId}`;
},
getEventName() {
return 'i-environment';
},
},
};
</script>

View File

@ -0,0 +1,86 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="items != null">
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>Dashboard</v-toolbar-title>
<v-spacer></v-spacer>
<div>
<v-tabs centered>
<v-tab key="history" :to="`/project/${projectId}/history`">History</v-tab>
<v-tab key="activity" :to="`/project/${projectId}/activity`">Activity</v-tab>
<v-tab key="settings" :to="`/project/${projectId}/settings`">Settings</v-tab>
</v-tabs>
</div>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="mt-4"
>
<template v-slot:item.tpl_alias="{ item }">
<a @click="showTaskLog(item.id)">{{ item.tpl_alias }}</a>
<span style="color: gray; margin-left: 10px;">#{{ item.id }}</span>
</template>
<template v-slot:item.status="{ item }">
{{ item.status }}
</template>
</v-data-table>
</div>
</template>
<script>
import ItemListPageBase from '@/components/ItemListPageBase';
import EventBus from '@/event-bus';
export default {
mixins: [ItemListPageBase],
watch: {
async projectId() {
await this.loadItems();
},
},
methods: {
showTaskLog(taskId) {
EventBus.$emit('i-show-task', {
taskId,
});
},
getHeaders() {
return [
{
text: 'Task',
value: 'tpl_alias',
sortable: false,
},
{
text: 'Status',
value: 'status',
sortable: false,
},
{
text: 'User',
value: 'user_name',
sortable: false,
},
{
text: 'Start',
value: 'start',
sortable: false,
},
{
text: 'Duration',
value: 'start',
sortable: false,
},
];
},
getItemsUrl() {
return `/api/project/${this.projectId}/tasks/last`;
},
},
};
</script>

View File

@ -0,0 +1,108 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="items != null">
<ItemDialog
v-model="editDialog"
:save-button-text="itemId === 'new' ? 'Create' : 'Save'"
:title="`${itemId === 'new' ? 'New' : 'Edit'} Inventory`"
:max-width="450"
@save="loadItems"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<InventoryForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<YesNoDialog
title="Delete inventory"
text="Are you really want to delete this inventory?"
v-model="deleteItemDialog"
@yes="deleteItem(itemId)"
/>
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>Inventory</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="editItem('new')"
>New Inventory</v-btn>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="mt-4"
:items-per-page="Number.MAX_VALUE"
>
<template v-slot:item.inventory="{ item }">
<div v-if="item.type === 'file'">{{ item.inventory }}</div>
</template>
<template v-slot:item.actions="{ item }">
<div style="white-space: nowrap">
<v-btn
icon
class="mr-1"
@click="askDeleteItem(item.id)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn
icon
class="mr-1"
@click="editItem(item.id)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</div>
</template>
</v-data-table>
</div>
</template>
<script>
import ItemListPageBase from '@/components/ItemListPageBase';
export default {
mixins: [ItemListPageBase],
methods: {
getHeaders() {
return [{
text: 'Name',
value: 'name',
},
{
text: 'Type',
value: 'type',
},
{
text: 'Path',
value: 'inventory',
},
{
text: 'Actions',
value: 'actions',
sortable: false,
}];
},
getItemsUrl() {
return `/api/project/${this.projectId}/inventory`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/inventory/${this.itemId}`;
},
getEventName() {
return 'i-inventory';
},
},
};
</script>

View File

@ -0,0 +1,118 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="items != null">
<ItemDialog
v-model="editDialog"
:save-button-text="itemId === 'new' ? 'Create' : 'Save'"
:title="`${itemId === 'new' ? 'New' : 'Edit'} Key`"
:max-width="450"
position="top"
@save="loadItems()"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<KeyForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<YesNoDialog
title="Delete key"
text="Are you really want to delete this key?"
v-model="deleteItemDialog"
@yes="deleteItem(itemId)"
/>
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>Key Store</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="editItem('new')"
>New Key</v-btn>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="mt-4"
:items-per-page="Number.MAX_VALUE"
>
<template v-slot:item.actions="{ item }">
<div style="white-space: nowrap">
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn
icon
class="mr-1"
v-bind="attrs"
v-on="on"
@click="askDeleteItem(item.id)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
<span>Delete key</span>
</v-tooltip>
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-btn
icon
class="mr-1"
v-bind="attrs"
v-on="on"
@click="editItem(item.id)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</template>
<span>Edit key</span>
</v-tooltip>
</div>
</template>
</v-data-table>
</div>
</template>
<script>
import ItemListPageBase from '@/components/ItemListPageBase';
import KeyForm from '@/components/KeyForm.vue';
export default {
components: { KeyForm },
mixins: [ItemListPageBase],
methods: {
getHeaders() {
return [{
text: 'Name',
value: 'name',
},
{
text: 'Type',
value: 'type',
},
{
text: 'Actions',
value: 'actions',
sortable: false,
}];
},
getItemsUrl() {
return `/api/project/${this.projectId}/keys`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/keys/${this.itemId}`;
},
getEventName() {
return 'i-keys';
},
},
};
</script>

View File

@ -0,0 +1,52 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div>
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>New Project</v-toolbar-title>
<v-spacer></v-spacer>
</v-toolbar>
<div class="project-settings-form">
<div style="height: 220px;">
<ProjectForm item-id="new" ref="editForm"/>
</div>
<div class="text-right">
<v-btn color="primary" @click="createProject()">Create</v-btn>
</div>
</div>
</div>
</template>
<style lang="scss">
</style>
<script>
import EventBus from '@/event-bus';
import ProjectForm from '@/components/ProjectForm.vue';
export default {
components: { ProjectForm },
data() {
return {
};
},
methods: {
showDrawer() {
EventBus.$emit('i-show-drawer');
},
async createProject() {
const item = await this.$refs.editForm.save();
if (!item) {
return;
}
EventBus.$emit('i-project', {
action: 'new',
item,
});
},
},
};
</script>

View File

@ -0,0 +1,125 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="items != null && keys != null">
<ItemDialog
v-model="editDialog"
:save-button-text="itemId === 'new' ? 'Create' : 'Save'"
:title="`${itemId === 'new' ? 'New' : 'Edit'} Repository`"
@save="loadItems()"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<RepositoryForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<YesNoDialog
title="Delete repository"
text="Are you really want to delete this repository?"
v-model="deleteItemDialog"
@yes="deleteItem(itemId)"
/>
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>Playbook Repositories</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="editItem('new')"
>New Repository</v-btn>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="mt-4"
:items-per-page="Number.MAX_VALUE"
>
<template v-slot:item.ssh_key_id="{ item }">
{{ keys.find((k) => k.id === item.ssh_key_id).name }}
</template>
<template v-slot:item.actions="{ item }">
<div style="white-space: nowrap">
<v-btn
icon
class="mr-1"
@click="askDeleteItem(item.id)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
<v-btn
icon
class="mr-1"
@click="editItem(item.id)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</div>
</template>
</v-data-table>
</div>
</template>
<script>
import ItemListPageBase from '@/components/ItemListPageBase';
import RepositoryForm from '@/components/RepositoryForm.vue';
import axios from 'axios';
export default {
mixins: [ItemListPageBase],
components: { RepositoryForm },
data() {
return {
keys: null,
};
},
async created() {
this.keys = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/keys`,
responseType: 'json',
})).data;
},
methods: {
getHeaders() {
return [{
text: 'Name',
value: 'name',
},
{
text: 'Git URL',
value: 'git_url',
},
{
text: 'SSH Key',
value: 'ssh_key_id',
},
{
text: 'Actions',
value: 'actions',
sortable: false,
}];
},
getItemsUrl() {
return `/api/project/${this.projectId}/repositories`;
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/repositories/${this.itemId}`;
},
getEventName() {
return 'i-repositories';
},
},
};
</script>

View File

@ -0,0 +1,114 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div>
<YesNoDialog
v-model="deleteProjectDialog"
title="Delete project"
text="Are you really want to delete this project?"
@yes="deleteProject()"
/>
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>Dashboard</v-toolbar-title>
<v-spacer></v-spacer>
<div>
<v-tabs centered>
<v-tab key="history" :to="`/project/${projectId}/history`">History</v-tab>
<v-tab key="activity" :to="`/project/${projectId}/activity`">Activity</v-tab>
<v-tab key="settings" :to="`/project/${projectId}/settings`">Settings</v-tab>
</v-tabs>
</div>
</v-toolbar>
<div class="project-settings-form">
<div style="height: 220px;">
<ProjectForm :item-id="projectId" ref="form"/>
</div>
<div class="text-right">
<v-btn color="primary" @click="saveProject()">Save</v-btn>
</div>
</div>
<div class="project-delete-form">
<v-row align="center">
<v-col class="shrink">
<v-btn color="error" @click="deleteProjectDialog = true">Delete Project</v-btn>
</v-col>
<v-col class="grow">
<div style="font-size: 14px; color: #ff5252">
Once you delete a project, there is no going back. Please be certain.
</div>
</v-col>
</v-row>
</div>
</div>
</template>
<style lang="scss">
.project-settings-form {
max-width: 400px;
margin: 80px auto auto;
}
.project-delete-form {
max-width: 400px;
margin: 80px auto auto;
}
</style>
<script>
import EventBus from '@/event-bus';
import ProjectForm from '@/components/ProjectForm.vue';
import { getErrorMessage } from '@/lib/error';
import axios from 'axios';
import YesNoDialog from '@/components/YesNoDialog.vue';
export default {
components: { YesNoDialog, ProjectForm },
props: {
projectId: Number,
},
data() {
return {
deleteProjectDialog: null,
};
},
methods: {
showDrawer() {
EventBus.$emit('i-show-drawer');
},
async saveProject() {
const item = await this.$refs.form.save();
if (!item) {
return;
}
EventBus.$emit('i-project', {
action: 'edit',
item,
});
},
async deleteProject() {
try {
await axios({
method: 'delete',
url: `/api/project/${this.projectId}`,
responseType: 'json',
});
EventBus.$emit('i-project', {
action: 'delete',
item: {
id: this.projectId,
},
});
} catch (err) {
EventBus.$emit('i-snackbar', {
color: 'error',
text: getErrorMessage(err),
});
}
},
},
};
</script>

View File

@ -0,0 +1,140 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="items != null">
<ItemDialog
v-model="editDialog"
:save-button-text="(this.itemId === 'new' ? 'Link' : 'Save')"
:title="(this.itemId === 'new' ? 'New' : 'Edit') + ' Team Member'"
@save="loadItems()"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TeamMemberForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<YesNoDialog
title="Delete team member"
text="Are you really want to delete the team member?"
v-model="deleteItemDialog"
@yes="deleteItem(itemId)"
/>
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>Team</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="editItem('new')"
>New Team Member</v-btn>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="mt-4"
:items-per-page="Number.MAX_VALUE"
>
<template v-slot:item.admin="{ item }">
<v-btn
icon
v-if="item.admin"
@click="refuseAdmin(item.id)"
:disabled="!isUserAdmin()"
>
<v-icon>mdi-checkbox-marked</v-icon>
</v-btn>
<v-btn
icon
v-else
@click="grantAdmin(item.id)"
:disabled="!isUserAdmin()"
>
<v-icon>mdi-checkbox-blank-outline</v-icon>
</v-btn>
</template>
<template v-slot:item.actions="{ item }">
<v-btn
icon
:disabled="!isUserAdmin()"
@click="askDeleteItem(item.id)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
</v-data-table>
</div>
</template>
<script>
import ItemListPageBase from '@/components/ItemListPageBase';
import TeamMemberForm from '@/components/TeamMemberForm.vue';
import axios from 'axios';
export default {
components: { TeamMemberForm },
mixins: [ItemListPageBase],
methods: {
async grantAdmin(userId) {
await axios({
method: 'post',
url: `/api/project/${this.projectId}/users/${userId}/admin`,
responseType: 'json',
});
await this.loadItems();
},
async refuseAdmin(userId) {
await axios({
method: 'delete',
url: `/api/project/${this.projectId}/users/${userId}/admin`,
responseType: 'json',
});
await this.loadItems();
},
getHeaders() {
return [
{
text: 'Name',
value: 'name',
},
{
text: 'Username',
value: 'username',
},
{
text: 'Email',
value: 'email',
},
{
text: 'Admin',
value: 'admin',
},
{
text: 'Actions',
value: 'actions',
sortable: false,
}];
},
getSingleItemUrl() {
return `/api/project/${this.projectId}/users/${this.itemId}`;
},
getItemsUrl() {
return `/api/project/${this.projectId}/users?sort=name&order=asc`;
},
getEventName() {
return 'i-repositories';
},
isUserAdmin() {
return (this.items.find((x) => x.id === this.userId) || {}).admin;
},
},
};
</script>

View File

@ -0,0 +1,273 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="item != null && tasks != null">
<ItemDialog
v-model="editDialog"
save-button-text="Save"
title="Edit Template"
@save="loadData()"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TemplateForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<ItemDialog
v-model="copyDialog"
save-button-text="Create"
title="New Template"
@save="loadData()"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TemplateForm
:project-id="projectId"
:item-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<YesNoDialog
title="Delete template"
text="Are you really want to delete this template?"
v-model="deleteDialog"
@yes="remove()"
/>
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title class="breadcrumbs">
<router-link
class="breadcrumbs__item breadcrumbs__item--link"
:to="`/project/${projectId}/templates/`"
>Task Templates</router-link>
<span class="breadcrumbs__separator">&gt;</span>
<span class="breadcrumbs__item">{{ item.alias }}</span>
</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
icon
color="error"
@click="deleteDialog = true"
>
<v-icon left>mdi-delete</v-icon>
</v-btn>
<v-btn
icon
color="black"
@click="copyDialog = true"
>
<v-icon left>mdi-content-copy</v-icon>
</v-btn>
<v-btn
icon
color="black"
@click="editDialog = true"
>
<v-icon left>mdi-pencil</v-icon>
</v-btn>
</v-toolbar>
<v-container class="pa-0">
<v-row>
<v-col>
<v-list two-line subheader>
<v-list-item>
<v-list-item-content>
<v-list-item-title>Playbook</v-list-item-title>
<v-list-item-subtitle>{{ item.playbook }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title>SSH Key</v-list-item-title>
<v-list-item-subtitle>{{ item.ssh_key_id }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-col>
<v-col>
<v-list two-line subheader>
<v-list-item>
<v-list-item-content>
<v-list-item-title>Inventory</v-list-item-title>
<v-list-item-subtitle>{{ item.inventory_id }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title>Environment</v-list-item-title>
<v-list-item-subtitle>{{ item.environment_id }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title>Repository</v-list-item-title>
<v-list-item-subtitle>{{ item.repository_id }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-col>
</v-row>
</v-container>
<h4 class="ml-4 mt-4">Task History</h4>
<v-data-table
:headers="headers"
:items="tasks"
hide-default-footer
class="mt-2"
>
<template v-slot:item.id="{ item }">
<a @click="showTaskLog(item.id)">#{{ item.id }}</a>
</template>
<template v-slot:item.status="{ item }">
{{ item.status }}
</template>
</v-data-table>
</div>
</template>
<style lang="scss">
</style>
<script>
import axios from 'axios';
import EventBus from '@/event-bus';
import { getErrorMessage } from '@/lib/error';
import YesNoDialog from '@/components/YesNoDialog.vue';
import ItemDialog from '@/components/ItemDialog.vue';
import TemplateForm from '@/components/TemplateForm.vue';
export default {
components: {
YesNoDialog, ItemDialog, TemplateForm,
},
props: {
projectId: Number,
},
data() {
return {
headers: [
{
text: 'Task ID',
value: 'id',
sortable: false,
},
{
text: 'Status',
value: 'status',
sortable: false,
},
{
text: 'User',
value: 'user_name',
sortable: false,
},
{
text: 'Start',
value: 'start',
sortable: false,
},
{
text: 'Duration',
value: 'start',
sortable: false,
},
],
tasks: null,
item: null,
deleteDialog: null,
editDialog: null,
copyDialog: null,
taskLogDialog: null,
taskId: null,
};
},
computed: {
itemId() {
return this.$route.params.templateId;
},
isNew() {
return this.itemId === 'new';
},
},
async created() {
if (this.isNew) {
await this.$router.replace({
path: `/project/${this.projectId}/templates/new/edit`,
});
} else {
await this.loadData();
}
},
methods: {
showTaskLog(taskId) {
EventBus.$emit('i-show-task', {
taskId,
});
},
showDrawer() {
EventBus.$emit('i-show-drawer');
},
async remove() {
try {
await axios({
method: 'delete',
url: `/api/project/${this.projectId}/templates/${this.itemId}`,
responseType: 'json',
});
EventBus.$emit('i-snackbar', {
color: 'success',
text: `Template "${this.item.alias}" deleted`,
});
await this.$router.push({
path: `/project/${this.projectId}/templates`,
});
} catch (err) {
EventBus.$emit('i-snackbar', {
color: 'error',
text: getErrorMessage(err),
});
} finally {
this.deleteDialog = false;
}
},
async loadData() {
this.item = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/templates/${this.itemId}`,
responseType: 'json',
})).data;
this.tasks = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/templates/${this.itemId}/tasks/last`,
responseType: 'json',
})).data;
},
},
};
</script>

View File

@ -0,0 +1,201 @@
<template xmlns:v-slot="http://www.w3.org/1999/XSL/Transform">
<div v-if="isLoaded">
<ItemDialog
v-model="editDialog"
save-button-text="Create"
title="New template"
@save="loadItems()"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TemplateForm
:project-id="projectId"
item-id="new"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<ItemDialog
v-model="newTaskDialog"
save-button-text="Run"
title="New Task"
@save="onTaskCreate"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<TaskForm
:project-id="projectId"
item-id="new"
:template-id="itemId"
@save="onSave"
@error="onError"
:need-save="needSave"
:need-reset="needReset"
/>
</template>
</ItemDialog>
<v-toolbar flat color="white">
<v-app-bar-nav-icon @click="showDrawer()"></v-app-bar-nav-icon>
<v-toolbar-title>Task Templates</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="editItem('new')"
>New template</v-btn>
</v-toolbar>
<v-data-table
:headers="headers"
:items="items"
hide-default-footer
class="mt-4"
>
<template v-slot:item.alias="{ item }">
<router-link :to="`/project/${projectId}/templates/${item.id}`">
{{ item.alias }}
</router-link>
</template>
<template v-slot:item.ssh_key_id="{ item }">
{{ keys.find((x) => x.id === item.ssh_key_id).name }}
</template>
<template v-slot:item.inventory_id="{ item }">
{{ inventory.find((x) => x.id === item.inventory_id).name }}
</template>
<template v-slot:item.environment_id="{ item }">
{{ environment.find((x) => x.id === item.environment_id).name }}
</template>
<template v-slot:item.repository_id="{ item }">
{{ repositories.find((x) => x.id === item.repository_id).name }}
</template>
<template v-slot:item.actions="{ item }">
<v-btn text color="black" class="pl-1 pr-2" @click="createTask(item.id)">
<v-icon class="pr-1">mdi-play</v-icon>
Run
</v-btn>
</template>
</v-data-table>
</div>
</template>
<style lang="scss">
</style>
<script>
import ItemListPageBase from '@/components/ItemListPageBase';
import TemplateForm from '@/components/TemplateForm.vue';
import axios from 'axios';
import TaskForm from '@/components/TaskForm.vue';
import EventBus from '@/event-bus';
export default {
components: { TemplateForm, TaskForm },
mixins: [ItemListPageBase],
async created() {
await this.loadData();
},
data() {
return {
keys: null,
inventory: null,
environment: null,
repositories: null,
newTaskDialog: null,
taskId: null,
};
},
computed: {
isLoaded() {
return this.items && this.keys && this.inventory && this.environment && this.repositories;
},
},
methods: {
onTaskCreate(e) {
EventBus.$emit('i-show-task', {
taskId: e.item.id,
});
},
createTask(itemId) {
this.itemId = itemId;
this.newTaskDialog = true;
},
getHeaders() {
return [
{
text: 'Alias',
value: 'alias',
},
{
text: 'Playbook',
value: 'playbook',
sortable: false,
},
{
text: 'SSH key',
value: 'ssh_key_id',
sortable: false,
},
{
text: 'Inventory',
value: 'inventory_id',
sortable: false,
},
{
text: 'Environment',
value: 'environment_id',
sortable: false,
},
{
text: 'Repository',
value: 'repository_id',
sortable: false,
},
{
text: 'Actions',
value: 'actions',
sortable: false,
},
];
},
getItemsUrl() {
return `/api/project/${this.projectId}/templates`;
},
async loadData() {
this.inventory = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/inventory`,
responseType: 'json',
})).data;
this.environment = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/environment`,
responseType: 'json',
})).data;
this.keys = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/keys`,
responseType: 'json',
})).data;
this.repositories = (await axios({
method: 'get',
url: `/api/project/${this.projectId}/repositories`,
responseType: 'json',
})).data;
},
},
};
</script>

View File

@ -0,0 +1,13 @@
import { expect } from 'chai';
import { shallowMount } from '@vue/test-utils';
import HelloWorld from '@/components/HelloWorld.vue';
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message';
const wrapper = shallowMount(HelloWorld, {
propsData: { msg },
});
expect(wrapper.text()).to.include(msg);
});
});

5
web2/vue.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
transpileDependencies: [
'vuetify',
],
};