refactor: improve setup and maintainability (#1067)
@@ -1,3 +0,0 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
15
ui/.eslintrc.cjs
Normal file
@@ -0,0 +1,15 @@
|
||||
/* eslint-env node */
|
||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
'@vue/eslint-config-prettier/skip-formatting'
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/essential",
|
||||
"eslint:recommended",
|
||||
"@vue/typescript/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
rules: {
|
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
'vue/valid-v-slot': ['error', {
|
||||
allowModifiers: true,
|
||||
}],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/__tests__/*.{j,t}s?(x)", "**/tests/unit/**/*.spec.{j,t}s?(x)"],
|
||||
env: {
|
||||
jest: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
ignorePatterns: ["src/suggestions/grammar/*.js", "src/views/playbook/vue-blocks/"],
|
||||
};
|
||||
21
ui/.prettierrc.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none",
|
||||
"plugins": [
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"importOrder": [
|
||||
"(.*).css$",
|
||||
"^@/components/(.*)$",
|
||||
"^lucide-vue-next",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^@/(.*)$"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true
|
||||
}
|
||||
44
ui/README.md
@@ -1,27 +1,39 @@
|
||||
## Project setup
|
||||
# catalyst
|
||||
|
||||
```
|
||||
yarn install
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```
|
||||
yarn serve
|
||||
```sh
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```
|
||||
yarn build
|
||||
```sh
|
||||
bun build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
bun lint
|
||||
```
|
||||
yarn lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
BIN
ui/bun.lockb
Executable file
16
ui/components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "default",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/assets/main.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"framework": "vite",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineConfig } from 'cypress'
|
||||
|
||||
export default defineConfig({
|
||||
chromeWebSecurity: false,
|
||||
e2e: {
|
||||
// We've imported your old cypress plugins here.
|
||||
// You may want to clean this up later by importing these.
|
||||
setupNodeEvents(on, config) {
|
||||
return require('./cypress/plugins/index.js')(on, config)
|
||||
},
|
||||
baseUrl: 'http://localhost',
|
||||
experimentalSessionAndOrigin: true,
|
||||
},
|
||||
})
|
||||
@@ -1,152 +0,0 @@
|
||||
beforeEach(() => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.login();
|
||||
|
||||
cy.getCookie('maut_user').should('exist');
|
||||
|
||||
cy.intercept('GET', '/api/userdata/demo', { fixture: 'userdata_demo.json' })
|
||||
cy.intercept('GET', '/api/users/demo', { fixture: 'user_demo.json' })
|
||||
})
|
||||
|
||||
if (Cypress.env('TEST') === 'tickets') {
|
||||
describe('tickets', {"retries": 3}, () => {
|
||||
it('open ticket', () => {
|
||||
cy.visit('http://localhost/ui/tickets');
|
||||
|
||||
// clear caql
|
||||
cy.get("#caqlbar > div > div > div > div > div:nth-child(2) > div > button").click();
|
||||
|
||||
// wait for results to load
|
||||
cy.get("#app > div > main > div > div > div > div > div > div.v-data-table__wrapper > table > tbody > tr:nth-child(1)").should('exist');
|
||||
cy.wait(1000);
|
||||
|
||||
// open ticket
|
||||
cy.contains("live zebra").click();
|
||||
|
||||
// assert url
|
||||
cy.url().should('eq', "http://localhost/ui/tickets/8123")
|
||||
|
||||
// assert title
|
||||
cy.get("h1").should("have.text", " Incident #8123: live zebra ")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (Cypress.env('TEST') === 'templates') {
|
||||
describe('templates', {"retries": 3}, () => {
|
||||
it('create template', () => {
|
||||
cy.get("#toggle_menu").click();
|
||||
cy.contains('Templates').click();
|
||||
cy.get("#toggle_menu").click();
|
||||
|
||||
cy.get("body").then($body => {
|
||||
if ($body.find('a[href="/ui/templates/description-only"]').length > 0) {
|
||||
cy.get('a[href="/ui/templates/description-only"]').trigger('mouseover');
|
||||
cy.get('a[href="/ui/templates/description-only"] button').click();
|
||||
cy.get("#delete-button").click();
|
||||
}
|
||||
});
|
||||
|
||||
cy.contains("New Template").click();
|
||||
cy.url().should('eq', "http://localhost/ui/templates/new");
|
||||
cy.get("#name-edit").click().clear().type("DescriptionOnly");
|
||||
cy.get("#template-edit #advanced").click({force: true});
|
||||
cy.get(".prism-editor__textarea").clear().type('{ "type": "object", "name": "Incident", "required": [ "description" ], "properties": { "description": { "title": "Description", "type": "string", "x-display": "textarea" } } }', {parseSpecialCharSequences: false});
|
||||
cy.contains("Create").click();
|
||||
|
||||
cy.get("#toggle_menu").click();
|
||||
cy.contains('Alerts').click();
|
||||
cy.get("#toggle_menu").click();
|
||||
|
||||
cy.contains("New Alert").click();
|
||||
cy.get("#title-edit").type("New Alert");
|
||||
cy.get("#templates-edit .v-input__slot").click();
|
||||
cy.contains("DescriptionOnly").click();
|
||||
cy.contains("Create").click();
|
||||
cy.url().should('match', /http:\/\/localhost\/ui\/tickets\/\d+/);
|
||||
|
||||
cy.get("#description").type("Lorem Ipsum");
|
||||
cy.contains("Save Details").click();
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (Cypress.env('TEST') === 'playbooks') {
|
||||
describe('playbooks', {"retries": 3}, () => {
|
||||
it('create playbook', () => {
|
||||
cy.get("#toggle_menu").click();
|
||||
cy.contains('Playbooks').click();
|
||||
cy.get("#toggle_menu").click();
|
||||
|
||||
cy.get("body").then($body => {
|
||||
if ($body.find('a[href="/ui/playbooks/test"]').length > 0) {
|
||||
cy.get('a[href="/ui/playbooks/test"]').trigger('mouseover');
|
||||
cy.get('a[href="/ui/playbooks/test"] button').click();
|
||||
cy.get("#delete-button").click();
|
||||
}
|
||||
});
|
||||
|
||||
cy.contains("New Playbook").click();
|
||||
cy.url().should('eq', "http://localhost/ui/playbooks/new");
|
||||
cy.get(".prism-editor__textarea").clear().type('name: Test\n' +
|
||||
'tasks:\n' +
|
||||
' input:\n' +
|
||||
' name: Enter something to hash\n' +
|
||||
'type: input\n' +
|
||||
'schema:\n' +
|
||||
' title: Something\n' +
|
||||
'type: object\n' +
|
||||
'properties:\n' +
|
||||
' something:\n' +
|
||||
' type: string\n' +
|
||||
'title: Something\n' +
|
||||
'default: ""\n' +
|
||||
'{backspace}{backspace}{backspace}next:\n' +
|
||||
' hash: "something != \'\'"\n' +
|
||||
'{backspace}{backspace}\n' +
|
||||
'hash:\n' +
|
||||
' name: Hash the something\n' +
|
||||
'type: automation\n' +
|
||||
'automation: hash.sha1\n' +
|
||||
'payload:\n' +
|
||||
' default: "playbook.tasks[\'input\'].data[\'something\']"\n' +
|
||||
'{backspace}next:\n' +
|
||||
' comment: "hash != \'\'"\n' +
|
||||
'{backspace}{backspace}\n' +
|
||||
'comment:\n' +
|
||||
' name: Comment the hash\n' +
|
||||
'type: automation\n' +
|
||||
'automation: comment\n' +
|
||||
'payload:\n' +
|
||||
' default: "playbook.tasks[\'hash\'].data[\'hash\']"\n' +
|
||||
'{backspace}next:\n' +
|
||||
' done: "done"\n' +
|
||||
'{backspace}{backspace}\n' +
|
||||
'done:\n' +
|
||||
' name: You can close this case now\n' +
|
||||
'type: task\n');
|
||||
cy.scrollTo('bottom');
|
||||
cy.contains("Create").click();
|
||||
|
||||
cy.get("#toggle_menu").click();
|
||||
cy.contains('Alerts').click();
|
||||
cy.get("#toggle_menu").click();
|
||||
|
||||
cy.contains("New Alert").click();
|
||||
cy.get("#title-edit").type("New Alert");
|
||||
cy.get("#playbooks-edit .v-input__slot").click();
|
||||
cy.contains("Test").click();
|
||||
cy.contains("Create").click();
|
||||
cy.url().should('match', /http:\/\/localhost\/ui\/tickets\/\d+/);
|
||||
|
||||
cy.scrollTo('bottom');
|
||||
cy.get(".playbook-test .tasks").contains("Enter something to hash").click();
|
||||
cy.get("#something").type("my test value");
|
||||
cy.contains("Complete").click();
|
||||
|
||||
// cy.wait(300 * 1000);
|
||||
// cy.contains("a94a8fe5ccb19ba61c4c0873d391e987982fbbd3").should('exist');
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"apikey": false,
|
||||
"blocked": false,
|
||||
"id": "demo",
|
||||
"roles": [ "admin" ]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"email": "demo@example.org",
|
||||
"id": "demo",
|
||||
"name": "Demo"
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************************
|
||||
// This example plugins/index.js can be used to load plugins
|
||||
//
|
||||
// You can change the location of this file or turn off loading
|
||||
// the plugins file with the 'pluginsFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/plugins-guide
|
||||
// ***********************************************************
|
||||
|
||||
// This function is called when a project is opened or re-opened (e.g. due to
|
||||
// the project's config changing)
|
||||
|
||||
/**
|
||||
* @type {Cypress.PluginConfig}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
module.exports = (on, config) => {
|
||||
// `on` is used to hook into various events Cypress emits
|
||||
// `config` is the resolved Cypress config
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// ***********************************************
|
||||
// custom commands
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
@@ -1,34 +0,0 @@
|
||||
// ***********************************************************
|
||||
// global configuration
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
import './commands'
|
||||
|
||||
Cypress.Cookies.defaults({
|
||||
preserve: 'user',
|
||||
})
|
||||
|
||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||
return false
|
||||
})
|
||||
|
||||
Cypress.Commands.add('login', (options = {}) => {
|
||||
if (Cypress.env('AUTH') === 'simple') {
|
||||
cy.contains("Name").click({force: true});
|
||||
cy.get("#username").type("tom");
|
||||
cy.contains("Password").click({force: true});
|
||||
cy.get("#password").type("tom");
|
||||
cy.get("button").contains("Login").click();
|
||||
} else if (Cypress.env('AUTH') === 'keycloak') {
|
||||
cy.get("#username").type("bob");
|
||||
cy.get("#password").type("bob");
|
||||
cy.get("#kc-login").click();
|
||||
} else if (Cypress.env('AUTH') === 'authelia') {
|
||||
cy.contains("Login with OIDC").should('be.visible').click();
|
||||
cy.get("#username-textfield").should('be.visible').type("bob@example.com");
|
||||
cy.get("#password-textfield").type("bob");
|
||||
cy.get("#sign-in-button").click();
|
||||
cy.get("#accept-button").should('be.visible').click();
|
||||
}
|
||||
})
|
||||
1
ui/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
13
ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/src/assets/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Catalyst</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="h-screen w-screen"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
preset: "@vue/cli-plugin-unit-jest/presets/typescript-and-babel",
|
||||
};
|
||||
135
ui/package.json
@@ -1,87 +1,68 @@
|
||||
{
|
||||
"name": "catalyst",
|
||||
"version": "0.2.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"test": "vue-cli-service test:unit",
|
||||
"lint": "vue-cli-service lint"
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@crinkles/digl": "^2.0.2",
|
||||
"@koumoul/vjsf": "2.23.2",
|
||||
"@mdi/font": "7.3.67",
|
||||
"@mdi/util": "0.3.2",
|
||||
"@types/luxon": "3.3.3",
|
||||
"@types/prismjs": "1.26.2",
|
||||
"@uppy/core": "1.20.1",
|
||||
"@uppy/tus": "1.9.2",
|
||||
"@uppy/vue": "0.2.7",
|
||||
"ajv": "8.12.0",
|
||||
"ant-design-vue": "1.7.8",
|
||||
"antlr4": "4.11.0",
|
||||
"axios": "1.5.1",
|
||||
"chart.js": "2.9.4",
|
||||
"core-js": "3.33.1",
|
||||
"d3": "^7.8.0",
|
||||
"graphlib": "2.1.8",
|
||||
"json-schema-editor-vue": "2.2.3",
|
||||
"just-kebab-case": "4.2.0",
|
||||
"less": "4.2.0",
|
||||
"less-loader": "11.1.3",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.4.3",
|
||||
"panzoom": "^9.4.3",
|
||||
"register-service-worker": "1.7.2",
|
||||
"splitpanes": "2.4.1",
|
||||
"swagger-ui": "4.13.0",
|
||||
"vue": "2.7.15",
|
||||
"vue-axios": "3.5.2",
|
||||
"vue-chartjs": "3.5.1",
|
||||
"vue-class-component": "7.2.6",
|
||||
"vue-cropperjs": "5.0.0",
|
||||
"vue-d3-network": "0.1.28",
|
||||
"vue-lodash": "2.1.2",
|
||||
"vue-luxon": "0.10.0",
|
||||
"vue-markdown": "2.2.4",
|
||||
"vue-native-websocket": "2.0.15",
|
||||
"vue-pipeline": "1.0.12",
|
||||
"vue-prism-editor": "1.3.0",
|
||||
"vue-property-decorator": "9.1.2",
|
||||
"vue-router": "3.6.5",
|
||||
"vuetify": "2.7.1",
|
||||
"vuex": "3.6.2",
|
||||
"yaml": "2.0.1"
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.1.3",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@formkit/auto-animate": "^0.8.2",
|
||||
"@tanstack/vue-query": "^5.32.0",
|
||||
"@unovis/ts": "^1.4.1",
|
||||
"@unovis/vue": "^1.4.1",
|
||||
"@vee-validate/rules": "^4.12.8",
|
||||
"@vee-validate/zod": "^4.12.8",
|
||||
"@vueuse/core": "^10.10.0",
|
||||
"add": "^2.0.6",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"easymde": "^2.18.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-vue-next": "^0.365.0",
|
||||
"marked": "^12.0.2",
|
||||
"pinia": "^2.1.7",
|
||||
"pocketbase": "^0.21.2",
|
||||
"radix-vue": "^1.6.2",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"v-calendar": "^3.1.2",
|
||||
"vee-validate": "^4.12.8",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"vue3-easymde": "^1.0.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/vue": "7.0.0",
|
||||
"@types/jest": "29.5.6",
|
||||
"@types/lodash": "4.14.200",
|
||||
"@types/vue-markdown": "2.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "5.62.0",
|
||||
"@typescript-eslint/parser": "5.62.0",
|
||||
"@vue/cli-plugin-babel": "4.5.19",
|
||||
"@vue/cli-plugin-eslint": "4.5.19",
|
||||
"@vue/cli-plugin-pwa": "4.5.19",
|
||||
"@vue/cli-plugin-router": "4.5.19",
|
||||
"@vue/cli-plugin-typescript": "4.5.19",
|
||||
"@vue/cli-plugin-unit-jest": "4.5.19",
|
||||
"@vue/cli-plugin-vuex": "4.5.19",
|
||||
"@vue/cli-service": "4.5.19",
|
||||
"@vue/compiler-sfc": "3.3.6",
|
||||
"@vue/eslint-config-typescript": "10.0.0",
|
||||
"@vue/test-utils": "2.4.1",
|
||||
"@babel/eslint-parser": "7.22.15",
|
||||
"cypress": "11.2.0",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-plugin-jest": "27.4.3",
|
||||
"eslint-plugin-vue": "7.20.0",
|
||||
"sass": "1.69.4",
|
||||
"sass-loader": "^10",
|
||||
"typescript": "5.1.6",
|
||||
"vue-cli-plugin-vuetify": "2.5.8",
|
||||
"vue-template-compiler": "2.7.15",
|
||||
"vuetify-loader": "1.9.2"
|
||||
"@rushstack/eslint-patch": "^1.3.3",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@tsconfig/node20": "^20.1.2",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/node": "^20.11.28",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"bun-types": "^1.1.6",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"npm-run-all2": "^6.1.2",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "~5.4.0",
|
||||
"vite": "^5.1.6",
|
||||
"vue-tsc": "^2.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 601 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
@@ -1,130 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="256.08"
|
||||
height="256.08"
|
||||
enable-background="new 0 0 100 100"
|
||||
fill="#333333"
|
||||
version="1.1"
|
||||
viewBox="0 0 85.36 85.36"
|
||||
xml:space="preserve"
|
||||
id="svg867"
|
||||
sodipodi:docname="flask_white.svg"
|
||||
inkscape:version="1.0 (4035a4f, 2020-05-01)"><defs
|
||||
id="defs871" /><sodipodi:namedview
|
||||
inkscape:document-rotation="0"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1615"
|
||||
inkscape:window-height="1254"
|
||||
id="namedview869"
|
||||
showgrid="false"
|
||||
inkscape:zoom="1.9033873"
|
||||
inkscape:cx="128.03999"
|
||||
inkscape:cy="103.03585"
|
||||
inkscape:window-x="3192"
|
||||
inkscape:window-y="41"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg867" />
|
||||
<metadata
|
||||
id="metadata833">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<path
|
||||
d="m27.345-37.335-17.33-30.02v-10.079h0.772c2.118 0 3.842-1.724 3.842-3.842 0-2.119-1.724-3.843-3.842-3.843h-15.182c-2.119 0-3.842 1.724-3.842 3.843s1.724 3.842 3.842 3.842h0.927v9.811l-17.487 30.288c-1.362 2.36-1.362 5.291 0 7.651 1.363 2.36 3.902 3.827 6.626 3.827h35.048c2.726 0 5.265-1.467 6.625-3.827 1.365-2.359 1.365-5.292 1e-3 -7.651zm-31.74-42.656c-0.708 0-1.285-0.576-1.285-1.285s0.576-1.285 1.285-1.285h15.183c0.708 0 1.283 0.577 1.283 1.285s-0.575 1.285-1.283 1.285h-2.052c-0.706 0-1.279 0.573-1.279 1.279v11.7c0 0.225 0.06 0.445 0.172 0.639l10.577 18.324c-0.034 5e-3 -0.069 3e-3 -0.104 9e-3 -0.345 0.055-0.651 0.102-0.889 0.102-0.916 0-1.835-0.238-2.731-0.708-0.43-0.228-0.889-0.335-1.344-0.335-0.969 0-1.91 0.488-2.456 1.361-1.131 1.813-3.039 2.894-5.104 2.894-2.854 0-5.286-2.003-5.914-4.87-0.203-0.932-0.851-1.702-1.732-2.061-0.351-0.145-0.723-0.215-1.092-0.215-0.554 0-1.107 0.16-1.584 0.474-0.987 0.646-2.112 0.987-3.25 0.987-0.517 0-1.074-0.097-1.753-0.307-0.155-0.049-0.311-0.081-0.467-0.103l2.373-4.109h11.535c0.706 0 1.279-0.573 1.279-1.279s-0.573-1.279-1.279-1.279h-10.058l1.872-3.241h7.688c0.707 0 1.279-0.573 1.279-1.279s-0.572-1.279-1.279-1.279h-6.21l1.808-3.13c0.026-0.036 0.051-0.074 0.076-0.112h4.825c0.706 0 1.279-0.573 1.279-1.279s-0.573-1.279-1.279-1.279h-4.606v-3.242h4.604c0.706 0 1.279-0.573 1.279-1.279s-0.573-1.279-1.279-1.279h-4.604v-3.824c0-0.706-0.573-1.279-1.279-1.279h-2.205z"
|
||||
id="path835" />
|
||||
<path
|
||||
d="m-4.538-97.293c3.839 0 6.962-3.123 6.962-6.962s-3.124-6.962-6.962-6.962-6.962 3.123-6.962 6.962 3.124 6.962 6.962 6.962zm0-11.367c2.429 0 4.405 1.976 4.405 4.404s-1.976 4.404-4.405 4.404-4.405-1.976-4.405-4.404 1.977-4.404 4.405-4.404z"
|
||||
id="path837" />
|
||||
<path
|
||||
d="m1.003-91.752c0 2.272 1.849 4.121 4.121 4.121s4.121-1.849 4.121-4.121-1.849-4.121-4.121-4.121-4.121 1.849-4.121 4.121zm4.121-1.563c0.862 0 1.563 0.701 1.563 1.563s-0.7 1.563-1.563 1.563-1.563-0.701-1.563-1.563 0.701-1.563 1.563-1.563z"
|
||||
id="path839" />
|
||||
<path
|
||||
d="m24.048-38.2-5.3303-9.2335c-3.1435-7.7748-21.883-13.817-29.584-2.4987l-6.7736 11.732c-1.1756 2.0369-1.1756 4.5667 0 6.6036 1.1764 2.0369 3.3678 3.3031 5.7189 3.3031h30.25c2.3528 0 4.5442-1.2662 5.7181-3.3031 1.1781-2.0361 1.1781-4.5676 8.63e-4 -6.6036z"
|
||||
fill="#fc0"
|
||||
stroke-width=".8631"
|
||||
id="path841" />
|
||||
<path
|
||||
d="m143.85-25.061c-0.554 0-1.107 0.16061-1.584 0.47461-0.987 0.646-2.112 0.98698-3.25 0.98698-0.517 0-1.0743-0.09729-1.7533-0.30729-0.39216 0.03998-0.72173 0.07672-1.1191 0.78581l-6.7734 11.732c-1.1756 2.0369-1.1756 4.5666 0 6.6035 1.1764 2.0369 3.3676 3.3027 5.7188 3.3027h30.25c2.3528 0 4.5443-1.2658 5.7181-3.3027 1.1781-2.0361 1.1786-4.5675 1e-3 -6.6035l-5.3307-9.2337c-0.45242-0.78245-0.70174-0.56556-1.5046-0.50452-0.916 0-1.8351-0.23768-2.7311-0.70768-0.43-0.228-0.88941-0.33529-1.3444-0.33529-0.969 0-1.9097 0.48833-2.4557 1.3613-1.131 1.813-3.0392 2.8939-5.1042 2.8939-2.854 0-5.2861-2.0034-5.9141-4.8704-0.203-0.932-0.85077-1.7015-1.7318-2.0605-0.351-0.145-0.7228-0.21549-1.0918-0.21549z"
|
||||
fill="#ffc107"
|
||||
id="path843" />
|
||||
<path
|
||||
d="m94.563-31.292-17.33-30.02v-10.079h0.772c2.118 0 3.842-1.724 3.842-3.842 0-2.119-1.724-3.843-3.842-3.843h-15.182c-2.119 0-3.842 1.724-3.842 3.843s1.724 3.842 3.842 3.842h0.927v9.811l-17.487 30.288c-1.362 2.36-1.362 5.291 0 7.651 1.363 2.36 3.902 3.827 6.626 3.827h35.048c2.726 0 5.265-1.467 6.625-3.827 1.365-2.359 1.365-5.292 1e-3 -7.651zm-31.74-42.656c-0.708 0-1.285-0.576-1.285-1.285s0.576-1.285 1.285-1.285h15.183c0.708 0 1.283 0.577 1.283 1.285s-0.575 1.285-1.283 1.285h-2.052c-0.706 0-1.279 0.573-1.279 1.279v11.7c0 0.225 0.06 0.445 0.172 0.639l10.577 18.324c-0.034 5e-3 -0.069 3e-3 -0.104 9e-3 -0.345 0.055-0.651 0.102-0.889 0.102-0.916 0-1.835-0.238-2.731-0.708-0.43-0.228-0.889-0.335-1.344-0.335-0.969 0-1.91 0.488-2.456 1.361-1.131 1.813-3.039 2.894-5.104 2.894-2.854 0-5.286-2.003-5.914-4.87-0.203-0.932-0.851-1.702-1.732-2.061-0.351-0.145-0.723-0.215-1.092-0.215-0.554 0-1.107 0.16-1.584 0.474-0.987 0.646-2.112 0.987-3.25 0.987-0.517 0-1.074-0.097-1.753-0.307-0.155-0.049-0.311-0.081-0.467-0.103l2.373-4.109h11.535c0.706 0 1.279-0.573 1.279-1.279s-0.573-1.279-1.279-1.279h-10.058l1.872-3.241h7.688c0.707 0 1.279-0.573 1.279-1.279s-0.572-1.279-1.279-1.279h-6.21l1.808-3.13c0.026-0.036 0.051-0.074 0.076-0.112h4.825c0.706 0 1.279-0.573 1.279-1.279s-0.573-1.279-1.279-1.279h-4.606v-3.242h4.604c0.706 0 1.279-0.573 1.279-1.279s-0.573-1.279-1.279-1.279h-4.604v-3.824c0-0.706-0.573-1.279-1.279-1.279h-2.205z"
|
||||
fill="#ffc107"
|
||||
id="path845" />
|
||||
<path
|
||||
d="m62.68-91.25c3.839 0 6.962-3.123 6.962-6.962 0-3.839-3.124-6.962-6.962-6.962s-6.962 3.123-6.962 6.962c0 3.839 3.124 6.962 6.962 6.962zm0-11.367c2.429 0 4.405 1.976 4.405 4.404 0 2.428-1.976 4.404-4.405 4.404s-4.405-1.976-4.405-4.404c0-2.428 1.977-4.404 4.405-4.404z"
|
||||
id="path847" />
|
||||
<path
|
||||
d="m68.221-85.709c0 2.272 1.849 4.121 4.121 4.121 2.272 0 4.121-1.849 4.121-4.121s-1.849-4.121-4.121-4.121c-2.272 0-4.121 1.849-4.121 4.121zm4.121-1.563c0.862 0 1.563 0.701 1.563 1.563s-0.7 1.563-1.563 1.563-1.563-0.701-1.563-1.563 0.701-1.563 1.563-1.563z"
|
||||
id="path849" />
|
||||
<path
|
||||
d="m62.824-79.076c-2.119 0-3.8424 1.7241-3.8424 3.8431s1.7244 3.8418 3.8424 3.8418h0.92709v9.8112l-17.488 30.288c-1.362 2.36-1.362 5.291 0 7.651 1.363 2.36 3.9023 3.8268 6.6263 3.8268h35.048c2.726 0 5.265-1.4668 6.625-3.8268 1.365-2.359 1.3647-5.292 6.7e-4 -7.651l-17.33-30.02v-10.079h0.77213c2.118 0 3.8418-1.7238 3.8418-3.8418 0-2.119-1.7238-3.8431-3.8418-3.8431zm0 2.5579h15.183c0.708 0 1.2826 0.57716 1.2826 1.2852s-0.57455 1.2852-1.2826 1.2852h-2.0521c-0.706 0-1.2793 0.57265-1.2793 1.2786v11.7c0 0.225 0.0599 0.44533 0.17188 0.63933 5.4876 9.5256 10.979 18.748 16.42 28.173 1.1773 2.0361 1.1768 4.5675-1e-3 6.6035-1.1738 2.0369-3.3653 3.3027-5.7181 3.3027h-30.25c-2.3511 0-4.5423-1.2658-5.7188-3.3027-1.1756-2.0369-1.1756-4.5666 0-6.6035 3.1075-5.7233 6.5841-11.164 9.7988-16.73h11.534c0.706 0 1.2793-0.57265 1.2793-1.2786s-0.5733-1.2793-1.2793-1.2793h-10.058l1.8724-3.2409h7.6875c0.707 0 1.2793-0.57265 1.2793-1.2786s-0.57229-1.2793-1.2793-1.2793h-6.2096l1.8079-3.1302c0.026-0.036 0.0512-0.07398 0.0762-0.11198h4.8249c0.706 0 1.2786-0.57265 1.2786-1.2786s-0.57264-1.2793-1.2786-1.2793h-4.6061v-3.2422h4.6042c0.706 0 1.2786-0.57265 1.2786-1.2786 0-0.706-0.57265-1.2793-1.2786-1.2793h-4.6042v-3.8236c0-0.706-0.57265-1.2793-1.2786-1.2793h-2.2051v-6.33e-4c-0.708 0-1.2852-0.57616-1.2852-1.2852s0.57615-1.2852 1.2852-1.2852z"
|
||||
id="path851" />
|
||||
<path
|
||||
d="m142.31-70.382c3.839 0 6.962-3.123 6.962-6.962s-3.124-6.962-6.962-6.962-6.962 3.123-6.962 6.962 3.124 6.962 6.962 6.962zm0-11.367c2.429 0 4.405 1.976 4.405 4.404s-1.976 4.404-4.405 4.404-4.405-1.976-4.405-4.404 1.977-4.404 4.405-4.404z"
|
||||
id="path853" />
|
||||
<path
|
||||
d="m147.85-64.841c0 2.272 1.849 4.121 4.121 4.121s4.121-1.849 4.121-4.121-1.849-4.121-4.121-4.121-4.121 1.849-4.121 4.121zm4.121-1.563c0.862 0 1.563 0.701 1.563 1.563s-0.7 1.563-1.563 1.563-1.563-0.701-1.563-1.563 0.701-1.563 1.563-1.563z"
|
||||
id="path855" />
|
||||
<path
|
||||
d="m142.46-58.208c-2.119 0-3.8424 1.7241-3.8424 3.8431s1.7244 3.8418 3.8424 3.8418h0.92709v9.8112l-17.488 30.288c-1.362 2.36-1.362 5.291 0 7.651 1.363 2.36 3.9023 3.8268 6.6263 3.8268h35.048c2.726 0 5.265-1.4668 6.625-3.8268 1.365-2.359 1.3647-5.292 6.7e-4 -7.651l-17.33-30.02v-10.079h0.77213c2.118 0 3.8418-1.7238 3.8418-3.8418 0-2.119-1.7238-3.8431-3.8418-3.8431zm0 2.5579h15.183c0.708 0 1.2826 0.57716 1.2826 1.2852s-0.57455 1.2852-1.2826 1.2852h-2.0521c-0.706 0-1.2793 0.57265-1.2793 1.2786v11.7c0 0.225 0.0599 0.44533 0.17188 0.63933 5.4876 9.5256 10.979 18.748 16.42 28.173 1.1773 2.0361 1.1768 4.5675-1e-3 6.6035-1.1738 2.0369-3.3653 3.3027-5.7181 3.3027h-30.25c-2.3511 0-4.5423-1.2658-5.7188-3.3027-1.1756-2.0369-1.1756-4.5666 0-6.6035 3.1075-5.7233 6.5841-11.164 9.7988-16.73h11.534c0.706 0 1.2793-0.57265 1.2793-1.2786s-0.5733-1.2793-1.2793-1.2793h-10.058l1.8724-3.2409h7.6875c0.707 0 1.2793-0.57265 1.2793-1.2786s-0.57229-1.2793-1.2793-1.2793h-6.2096l1.8079-3.1302c0.026-0.036 0.0512-0.07398 0.0762-0.11198h4.8249c0.706 0 1.2786-0.57265 1.2786-1.2786s-0.57264-1.2793-1.2786-1.2793h-4.6061v-3.2422h4.6042c0.706 0 1.2786-0.57265 1.2786-1.2786 0-0.706-0.57265-1.2793-1.2786-1.2793h-4.6042v-3.8236c0-0.706-0.57265-1.2793-1.2786-1.2793h-2.2051v-6.33e-4c-0.708 0-1.2852-0.57616-1.2852-1.2852s0.57615-1.2852 1.2852-1.2852z"
|
||||
id="path857" />
|
||||
<path
|
||||
d="m 37.058802,58.744 c -2.211238,-0.326623 -2.304431,1.967133 -6.515502,1.58573 -5.604733,-0.507629 -8.112707,8.708553 -10.4653,13.24817 -1.2754,2.21 -1.2754,4.9546 0,7.1646 1.2764,2.21 3.6538,3.5833 6.2046,3.5833 h 32.82 c 2.5527,0 4.9304,-1.3733 6.2039,-3.5833 1.2782,-2.2091 1.2787,-4.9555 0.001,-7.1646 C 61.09397,67.092458 60.812829,60.786799 55.987302,61.821109 52.840114,62.495681 48.904068,64.285395 45.3734,63.638435 42.209999,63.058773 40.240353,59.213948 37.058802,58.744 Z"
|
||||
fill="#ffc107"
|
||||
stroke-width="1.085"
|
||||
id="path859"
|
||||
sodipodi:nodetypes="asccssccsaa" />
|
||||
<path
|
||||
style="fill:#fcfcfc;fill-opacity:1"
|
||||
d="m 52.101829,13.924 c 3.839,0 6.962,-3.123 6.962,-6.962 0,-3.839 -3.124,-6.962 -6.962,-6.962 -3.838,0 -6.962,3.123 -6.962,6.962 0,3.839 3.124,6.962 6.962,6.962 z m 0,-11.367 c 2.429,0 4.405,1.976 4.405,4.404 0,2.428 -1.976,4.404 -4.405,4.404 -2.429,0 -4.405,-1.976 -4.405,-4.404 0,-2.428 1.977,-4.404 4.405,-4.404 z"
|
||||
fill="#212121"
|
||||
stroke-width="1.0003"
|
||||
id="path861" />
|
||||
<path
|
||||
style="fill:#fcfcfc;fill-opacity:1"
|
||||
d="m 36.145092,12.263787 c 0,2.272 1.849,4.121 4.121,4.121 2.272,0 4.121,-1.849 4.121,-4.121 0,-2.2720002 -1.849,-4.1210002 -4.121,-4.1210002 -2.272,0 -4.121,1.849 -4.121,4.1210002 z m 4.121,-1.563 c 0.862,0 1.563,0.701 1.563,1.563 0,0.862 -0.7,1.563 -1.563,1.563 -0.863,0 -1.563,-0.701 -1.563,-1.563 0,-0.862 0.701,-1.563 1.563,-1.563 z"
|
||||
fill="#212121"
|
||||
id="path863" />
|
||||
<path
|
||||
id="path877"
|
||||
style="fill:#dea806;fill-opacity:1"
|
||||
d="M 170.52344 185.13477 C 169.71895 185.16191 168.86572 185.26896 167.96094 185.46289 C 166.48482 185.77928 164.93554 186.18969 163.35742 186.62891 C 167.89152 193.29507 171.23644 204.69326 178.76758 216.28516 C 182.60068 222.91246 182.59827 231.152 178.76367 237.7793 C 174.94317 244.4093 167.73492 247.45646 160.15234 248.5293 L 128.72266 252.97656 L 177.30859 252.97656 C 184.96669 252.97656 192.09942 248.85656 195.91992 242.22656 C 199.75452 235.59926 199.75497 227.36167 195.92188 220.73438 C 184.07132 202.49407 182.59072 184.72756 170.52344 185.13477 z "
|
||||
transform="scale(0.33333335)" /><path
|
||||
style="fill:#fcfcfc;fill-opacity:1"
|
||||
d="m 33.09,26.098 c -2.119,0 -3.8424,1.7241 -3.8424,3.8431 0,2.119 1.7244,3.8418 3.8424,3.8418 h 0.92709 v 9.8112 l -15.488,28.288 c -1.362,2.36 -1.362,5.957667 0,8.317667 1.363,2.36 3.9023,5.160133 6.6263,5.160133 h 35.048 c 2.726,0 5.265,-2.800133 6.625,-5.160133 1.365,-2.359 1.3647,-5.958667 6.7e-4,-8.317667 l -15.33,-28.02 v -10.079 h 0.77213 c 2.118,0 3.8418,-1.7238 3.8418,-3.8418 0,-2.119 -1.7238,-3.8431 -3.8418,-3.8431 z m 0,2.5579 h 19.183 c 0.708,0 1.2826,0.57716 1.2826,1.2852 0,0.70804 -0.57455,1.2852 -1.2826,1.2852 h -2.0521 c -0.706,0 -1.2793,0.57265 -1.2793,1.2786 v 11.7 c 0,0.225 0.0599,0.44533 0.17188,0.63933 5.4876,9.5256 8.979,16.748 14.42,26.173 1.1773,2.0361 1.1768,5.234167 -10e-4,7.270167 -1.1738,2.0369 -3.3653,4.636033 -5.7181,4.636033 h -30.25 c -2.3511,0 -4.5423,-2.599133 -5.7188,-4.636033 -1.1756,-2.0369 -1.1756,-5.233267 0,-7.270167 l 11.1472,-20.5288 h 7.020833 c 0.707,0 1.2793,-0.57265 1.2793,-1.2786 0,-0.70595 -0.57229,-1.2793 -1.2793,-1.2793 H 34.47068 l 1.8079,-3.1302 c 0.026,-0.036 0.0512,-0.07398 0.0762,-0.11198 h 4.158233 c 0.706,0 1.2786,-0.57265 1.2786,-1.2786 0,-0.70595 -0.57264,-1.2793 -1.2786,-1.2793 H 36.57358 v -3.2422 h 3.937533 c 0.706,0 1.2786,-0.57265 1.2786,-1.2786 0,-0.706 -0.57265,-1.2793 -1.2786,-1.2793 H 36.57358 v -3.8236 c 0,-0.706 -0.57265,-1.2793 -1.2786,-1.2793 h -2.2051 v -6.33e-4 c -0.708,0 -1.2852,-0.57616 -1.2852,-1.2852 0,-0.70904 0.57615,-1.2852 1.2852,-1.2852 z"
|
||||
fill="#212121"
|
||||
id="path865"
|
||||
sodipodi:nodetypes="cssccccssccccsscccsssssscccsscccssscccsssccssscssccscc" />
|
||||
<path
|
||||
style="fill:#fcfcfc;fill-opacity:1"
|
||||
id="path873"
|
||||
fill="#212121"
|
||||
d="m 45.676109,19.570901 c 0,2.272 1.849,4.121 4.121,4.121 2.272,0 4.121,-1.849 4.121,-4.121 0,-2.272 -1.849,-4.121 -4.121,-4.121 -2.272,0 -4.121,1.849 -4.121,4.121 z m 4.121,-1.563 c 0.862,0 1.563,0.701 1.563,1.563 0,0.862 -0.7,1.563 -1.563,1.563 -0.863,0 -1.563,-0.701 -1.563,-1.563 0,-0.862 0.701,-1.563 1.563,-1.563 z" /><ellipse
|
||||
style="fill:#fcfcfc;fill-opacity:1;stroke:#fcfcfc;stroke-width:0.914604;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
|
||||
id="path875"
|
||||
cx="42.891182"
|
||||
cy="23.405575"
|
||||
rx="1.209365"
|
||||
ry="1.2093649" /></svg>
|
||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,15 +0,0 @@
|
||||
<!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">
|
||||
<title>Catalyst</title>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "./img/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/android-chrome-maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/android-chrome-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/apple-touch-icon-60x60.png",
|
||||
"sizes": "60x60",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/apple-touch-icon-76x76.png",
|
||||
"sizes": "76x76",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/apple-touch-icon-120x120.png",
|
||||
"sizes": "120x120",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/apple-touch-icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/apple-touch-icon-180x180.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/favicon-16x16.png",
|
||||
"sizes": "16x16",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/favicon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/msapplication-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/mstile-150x150.png",
|
||||
"sizes": "150x150",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
396
ui/src/App.vue
@@ -1,392 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-app v-if="!authenticated" id="app" class="background-dark">
|
||||
<v-container class="login d-flex flex-column justify-center">
|
||||
<v-form ref="form" v-model="valid" @submit.prevent="login">
|
||||
<v-card class="pa-4">
|
||||
<div class="d-flex justify-center">
|
||||
<v-img src="/ui/static/flask.svg" height="100" width="100" class="flex-grow-0"></v-img>
|
||||
</div>
|
||||
<v-card-title class="text-center justify-center">
|
||||
Catalyst Login
|
||||
</v-card-title>
|
||||
<v-card-text v-if="hassimple" class="text-center">
|
||||
<v-text-field id="username" name="username" label="Name" v-model="username" :rules="[
|
||||
v => !!v || 'Name is required',
|
||||
]"></v-text-field>
|
||||
<v-text-field
|
||||
id="password"
|
||||
name="password"
|
||||
label="Password"
|
||||
:append-icon="show ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="show ? 'text' : 'password'"
|
||||
@click:append="show = !show"
|
||||
v-model="password"
|
||||
:rules="[
|
||||
v => !!v || 'Password is required',
|
||||
// v => (v && v.length > 8) || 'Password must be more than 8 characters',
|
||||
]"></v-text-field>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-center">
|
||||
<v-btn v-if="hasoidc" text href="/auth/oidclogin">
|
||||
Login with OIDC
|
||||
</v-btn>
|
||||
<v-spacer v-if="hassimple"></v-spacer>
|
||||
<v-btn v-if="hassimple" type="submit" color="primary" elevation="0" :disabled="!valid">
|
||||
Login
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</v-container>
|
||||
</v-app>
|
||||
<v-app v-else class="background">
|
||||
<v-navigation-drawer dark permanent :mini-variant="mini" :expand-on-hover="mini" app color="statusbar">
|
||||
<v-list>
|
||||
<v-list-item class="px-2" :to="{ name: 'Home' }">
|
||||
<v-list-item-avatar rounded="0">
|
||||
<v-img src="/ui/static/flask_white.svg" :width="40"></v-img>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="title">
|
||||
Catalyst
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-list nav dense>
|
||||
<v-list-item>
|
||||
<v-list-item-icon>
|
||||
<v-icon class="my-1">mdi-arrow-right-bold</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>
|
||||
<v-text-field
|
||||
placeholder="Goto"
|
||||
outlined
|
||||
dense
|
||||
hide-details
|
||||
v-on:keyup.enter="enter"
|
||||
clearable
|
||||
color="#fff"
|
||||
v-model="goto"></v-text-field>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<AppLink :links="internal"></AppLink>
|
||||
|
||||
<v-list nav dense v-if="$store.state.settings.ticketTypes">
|
||||
<v-list-item
|
||||
v-for="customType in $store.state.settings.ticketTypes"
|
||||
:key="customType.id"
|
||||
link
|
||||
:class="{ 'v-list-item--active': ($route.params.type === customType.id) }"
|
||||
@click="openTicketList(customType.id)">
|
||||
<v-list-item-icon>
|
||||
<v-badge
|
||||
v-if="customType.id in counts && counts[customType.id] > 0"
|
||||
:content="counts[customType.id]"
|
||||
color="red"
|
||||
left
|
||||
offset-x="35"
|
||||
offset-y="8"
|
||||
bottom>
|
||||
<v-icon>{{ customType.icon }}</v-icon>
|
||||
</v-badge>
|
||||
<v-icon v-else>{{ customType.icon }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>{{ customType.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<AppLink :links="settings"></AppLink>
|
||||
|
||||
<template v-slot:append>
|
||||
|
||||
<v-list nav dense>
|
||||
<v-list-item class="version" dense style="min-height: 20px">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title style="text-align: center; opacity: 0.5;">
|
||||
{{ $store.state.settings.version }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider></v-divider>
|
||||
<v-list nav dense>
|
||||
|
||||
<v-list-item :to="{ name: 'API' }">
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-share-variant</v-icon>
|
||||
</v-list-item-icon>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>API Documentation</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
<v-app-bar app dense flat absolute color="transparent">
|
||||
<v-btn icon @click="mini = !mini">
|
||||
<v-icon id="toggle_menu" color="primary">mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<v-breadcrumbs :items="crumbs">
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item
|
||||
:to="item.to"
|
||||
class="text-subtitle-2 crumb-item"
|
||||
:disabled="item.disabled"
|
||||
exact
|
||||
>
|
||||
{{ item.text }}
|
||||
</v-breadcrumbs-item>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-menu left bottom offset-y>
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<v-btn
|
||||
icon
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
>
|
||||
<v-avatar v-if="$store.state.userdata.image" size="32">
|
||||
<v-img :src="$store.state.userdata.image"></v-img>
|
||||
</v-avatar>
|
||||
<v-icon v-else>mdi-account-circle</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item :to="{ name: 'Profile' }">
|
||||
<v-list-item-title>Account</v-list-item-title>
|
||||
<v-list-item-icon><v-icon>mdi-account-circle</v-icon></v-list-item-icon>
|
||||
</v-list-item>
|
||||
<v-list-item @click="logout">
|
||||
<v-list-item-title>Logout</v-list-item-title>
|
||||
<v-list-item-icon><v-icon>mdi-logout</v-icon></v-list-item-icon>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
</v-app-bar>
|
||||
<router-view></router-view>
|
||||
<v-snackbar id="alert" v-model="snackbar" :color="$store.state.alert.type" :timeout="$store.state.alert.type === 'error' ? -1 : 5000" outlined>
|
||||
<b style="display: block">{{ $store.state.alert.name | capitalize }}</b>
|
||||
{{ $store.state.alert.detail }}
|
||||
<template v-slot:action="{ attrs }">
|
||||
<v-btn text v-bind="attrs" @click="snackbar = false">Close</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-app>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
import AppLink from "./components/AppLink.vue";
|
||||
import router from "vue-router";
|
||||
import {API} from "@/services/api";
|
||||
|
||||
export default Vue.extend({
|
||||
name: "App",
|
||||
components: {AppLink},
|
||||
data: () => ({
|
||||
show: false,
|
||||
hassimple: false,
|
||||
hasoidc: false,
|
||||
username: "",
|
||||
password: "",
|
||||
valid: true,
|
||||
authenticated: false,
|
||||
settings: [
|
||||
{ icon: "mdi-format-list-bulleted-type", name: "Ticket Types", to: "TicketTypeList", role: "tickettype:write" },
|
||||
{ icon: "mdi-file-hidden", name: "Templates", to: "TemplateList", role: "template:read" },
|
||||
{ icon: "mdi-file-cog-outline", name: "Playbooks", to: "PlaybookList", role: "playbook:read" },
|
||||
{ icon: "mdi-flash", name: "Automations", to: "AutomationList", role: "automation:read" },
|
||||
{ icon: "mdi-filter", name: "Ingestion Rules", to: "RuleList", role: "rule:read", tier: "enterprise" },
|
||||
{ icon: "mdi-account", name: "Users & API Keys", to: "UserList", role: "user:write" },
|
||||
{ icon: "mdi-account-group", name: "Groups", to: "GroupList", role: "group:write", tier: "enterprise" },
|
||||
{ icon: "mdi-cogs", name: "User Data", to: "UserDataList", role: "userdata:write" },
|
||||
{ icon: "mdi-format-list-checks", name: "Jobs", to: "JobList", role: "job:write" },
|
||||
{ icon: "mdi-cog", name: "Settings", to: "Settings", role: "settings:write" },
|
||||
],
|
||||
mini: true,
|
||||
goto: "",
|
||||
|
||||
snackbar: false,
|
||||
}),
|
||||
watch: {
|
||||
showAlert: function () {
|
||||
this.snackbar = true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
counts: function (): number {
|
||||
return this.$store.state.counts
|
||||
},
|
||||
internal: function (): Array<any> {
|
||||
return [
|
||||
{ icon: "mdi-view-dashboard", name: "Dashboards", to: "DashboardList", role: "dashboard:read" },
|
||||
{ icon: "mdi-check-bold", name: "Open Tasks", to: "TaskList", count: this.$store.state.task_count },
|
||||
]
|
||||
},
|
||||
showAlert: function (): boolean {
|
||||
return this.$store.state.showAlert
|
||||
},
|
||||
crumbs: function() {
|
||||
this.$route.name
|
||||
|
||||
let pathArray = this.$route.path.split("/")
|
||||
pathArray.shift()
|
||||
|
||||
return this.lodash.reduce(pathArray, (breadcrumbs, path, idx) => {
|
||||
let to = {};
|
||||
let text = path;
|
||||
|
||||
let toPath = breadcrumbs[idx - 1] ? "/" + breadcrumbs[idx - 1].xpath + "/" + path : "/" + path;
|
||||
let resolved = this.$router.resolve(toPath);
|
||||
if (resolved) {
|
||||
to = { name: resolved.resolved.name, params: resolved.resolved.params };
|
||||
text = resolved.resolved.meta && resolved.resolved.meta.title ? resolved.resolved.meta.title : text;
|
||||
}
|
||||
|
||||
breadcrumbs.push({ xpath: path, to: to, text: text });
|
||||
return breadcrumbs;
|
||||
}, [] as Array<any>);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
enter: function () {
|
||||
if (!this.goto) {
|
||||
return
|
||||
}
|
||||
this.$router.push({
|
||||
name: "Ticket",
|
||||
params: { id: this.goto.toString() }
|
||||
});
|
||||
},
|
||||
openTicketList: function (type: string) {
|
||||
(this.$router as any).history.current = router.START_LOCATION;
|
||||
this.$router.push({ name: "TicketList", params: { type: type } });
|
||||
},
|
||||
hasRole: function (s: string) {
|
||||
if (this.$store.state.settings.roles) {
|
||||
return this.lodash.includes(this.$store.state.settings.roles, s);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
login: function () {
|
||||
this.axios.post(
|
||||
"/auth/login",
|
||||
{username: this.username, password: this.password},
|
||||
).then((response) => {
|
||||
console.log(response.data);
|
||||
if (!this.lodash.isObject(response.data)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$store.dispatch("getUser");
|
||||
this.$store.dispatch("getUserData");
|
||||
this.$store.dispatch("getSettings");
|
||||
|
||||
this.authenticated = true;
|
||||
}).catch(() => {
|
||||
this.valid = false;
|
||||
})
|
||||
},
|
||||
logout: function () {
|
||||
this.axios.post("/auth/logout").then(() => {
|
||||
this.authenticated = false;
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.axios.get("/auth/config").then((response) => {
|
||||
this.hassimple = response.data.simple;
|
||||
this.hasoidc = response.data.oidc;
|
||||
|
||||
API.currentUser().then((response) => {
|
||||
if (!this.lodash.isObject(response.data)) {
|
||||
if (!this.hassimple && this.hasoidc) {
|
||||
window.location.href = "/auth/oidclogin";
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.authenticated = true;
|
||||
|
||||
this.$store.dispatch("getUser");
|
||||
this.$store.dispatch("getUserData");
|
||||
this.$store.dispatch("getSettings");
|
||||
})
|
||||
}).catch(() => {
|
||||
this.hassimple = false;
|
||||
this.hasoidc = false;
|
||||
})
|
||||
},
|
||||
});
|
||||
<script setup lang="ts">
|
||||
import Toaster from '@/components/ui/toast/Toaster.vue'
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.background {
|
||||
background-color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
.background-dark {
|
||||
background-color: #212121 !important;
|
||||
}
|
||||
|
||||
.login {
|
||||
height: 100%;
|
||||
max-width: 400px !important;
|
||||
}
|
||||
|
||||
.v-app-bar.v-toolbar--dense .v-toolbar__content {
|
||||
border-bottom: 1px solid #e0e0e0 !important;
|
||||
}
|
||||
|
||||
.theme--dark.background {
|
||||
background-color: #303030 !important;
|
||||
}
|
||||
|
||||
.theme--dark h1,
|
||||
.theme--dark h2,
|
||||
.theme--dark h3,
|
||||
.theme--dark h4 {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.theme--dark.v-btn:hover::before {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.theme--dark .v-application .primary--text {
|
||||
text-shadow: 0 0 3px #FFC107; /* #00bcd4;/* 0 0 3px #ffff00; */
|
||||
}
|
||||
|
||||
.theme--dark .glow,
|
||||
.theme--dark .v-list .v-list-item--active,
|
||||
.theme--dark.v-btn:hover,
|
||||
.theme--dark a:hover {
|
||||
color: #FFC107 !important; /* #00bcd4 !important;/* #ffff00 !important; */
|
||||
text-shadow: 0 0 3px #FFC107 !important; /* #00bcd4 !important;/* 0 0 3px #ffff00 !important; */
|
||||
}
|
||||
|
||||
/* box-shadow: 0 0 8px rgba(255, 255, 0, 0.2) !important; */
|
||||
|
||||
.v-navigation-drawer--mini-variant .version {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<RouterView />
|
||||
<Toaster />
|
||||
</template>
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
90
ui/src/assets/main.css
Normal file
@@ -0,0 +1,90 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%; /* zinc-950 */
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
|
||||
--primary: 346.8 77.2% 49.8%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%; /* zinc-900 */
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 346.8 77.2% 49.8%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
--vis-tooltip-background-color: none !important;
|
||||
--vis-tooltip-border-color: none !important;
|
||||
--vis-tooltip-text-color: none !important;
|
||||
--vis-tooltip-shadow-color: none !important;
|
||||
--vis-tooltip-backdrop-filter: none !important;
|
||||
--vis-tooltip-padding: none !important;
|
||||
|
||||
--vis-primary-color: var(--primary);
|
||||
/* change to any hsl value you want */
|
||||
--vis-secondary-color: 160 81% 40%;
|
||||
--vis-text-color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 0 0% 95%;
|
||||
|
||||
--card: 24 9.8% 10%;
|
||||
--card-foreground: 0 0% 95%;
|
||||
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 95%;
|
||||
|
||||
--primary: 346.8 77.2% 49.8%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 346.8 77.2% 49.8%;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings:
|
||||
'rlig' 1,
|
||||
'calt' 1;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm
|
||||
8722
ui/src/client/api.ts
@@ -1,71 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
*
|
||||
* API for the catalyst incident response platform.
|
||||
*
|
||||
* The version of the OpenAPI document:
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
import { Configuration } from "./configuration";
|
||||
// Some imports not used depending on template conditions
|
||||
// @ts-ignore
|
||||
import globalAxios, { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
|
||||
export const BASE_PATH = "http://./api".replace(/\/+$/, "");
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const COLLECTION_FORMATS = {
|
||||
csv: ",",
|
||||
ssv: " ",
|
||||
tsv: "\t",
|
||||
pipes: "|",
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface RequestArgs
|
||||
*/
|
||||
export interface RequestArgs {
|
||||
url: string;
|
||||
options: AxiosRequestConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @class BaseAPI
|
||||
*/
|
||||
export class BaseAPI {
|
||||
protected configuration: Configuration | undefined;
|
||||
|
||||
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
|
||||
if (configuration) {
|
||||
this.configuration = configuration;
|
||||
this.basePath = configuration.basePath || this.basePath;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @class RequiredError
|
||||
* @extends {Error}
|
||||
*/
|
||||
export class RequiredError extends Error {
|
||||
name: "RequiredError" = "RequiredError";
|
||||
constructor(public field: string, msg?: string) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
*
|
||||
* API for the catalyst incident response platform.
|
||||
*
|
||||
* The version of the OpenAPI document:
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
import { Configuration } from "./configuration";
|
||||
import { RequiredError, RequestArgs } from "./base";
|
||||
import { AxiosInstance, AxiosResponse } from 'axios';
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const DUMMY_BASE_URL = 'https://example.com'
|
||||
|
||||
/**
|
||||
*
|
||||
* @throws {RequiredError}
|
||||
* @export
|
||||
*/
|
||||
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
|
||||
if (paramValue === null || paramValue === undefined) {
|
||||
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
|
||||
if (configuration && configuration.apiKey) {
|
||||
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
|
||||
? await configuration.apiKey(keyParamName)
|
||||
: await configuration.apiKey;
|
||||
object[keyParamName] = localVarApiKeyValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
|
||||
if (configuration && (configuration.username || configuration.password)) {
|
||||
object["auth"] = { username: configuration.username, password: configuration.password };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
|
||||
if (configuration && configuration.accessToken) {
|
||||
const accessToken = typeof configuration.accessToken === 'function'
|
||||
? await configuration.accessToken()
|
||||
: await configuration.accessToken;
|
||||
object["Authorization"] = "Bearer " + accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
|
||||
if (configuration && configuration.accessToken) {
|
||||
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
|
||||
? await configuration.accessToken(name, scopes)
|
||||
: await configuration.accessToken;
|
||||
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const setSearchParams = function (url: URL, ...objects: any[]) {
|
||||
const searchParams = new URLSearchParams(url.search);
|
||||
for (const object of objects) {
|
||||
for (const key in object) {
|
||||
if (Array.isArray(object[key])) {
|
||||
searchParams.delete(key);
|
||||
for (const item of object[key]) {
|
||||
searchParams.append(key, item);
|
||||
}
|
||||
} else {
|
||||
searchParams.set(key, object[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
url.search = searchParams.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
|
||||
const nonString = typeof value !== 'string';
|
||||
const needsSerialization = nonString && configuration && configuration.isJsonMime
|
||||
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
|
||||
: nonString;
|
||||
return needsSerialization
|
||||
? JSON.stringify(value !== undefined ? value : {})
|
||||
: (value || "");
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const toPathString = function (url: URL) {
|
||||
return url.pathname + url.search + url.hash
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
|
||||
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
|
||||
const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || basePath) + axiosArgs.url};
|
||||
return axios.request<T, R>(axiosRequestArgs);
|
||||
};
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
*
|
||||
* API for the catalyst incident response platform.
|
||||
*
|
||||
* The version of the OpenAPI document:
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
export interface ConfigurationParameters {
|
||||
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
|
||||
username?: string;
|
||||
password?: string;
|
||||
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
|
||||
basePath?: string;
|
||||
baseOptions?: any;
|
||||
formDataCtor?: new () => any;
|
||||
}
|
||||
|
||||
export class Configuration {
|
||||
/**
|
||||
* parameter for apiKey security
|
||||
* @param name security name
|
||||
* @memberof Configuration
|
||||
*/
|
||||
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
|
||||
/**
|
||||
* parameter for basic security
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof Configuration
|
||||
*/
|
||||
username?: string;
|
||||
/**
|
||||
* parameter for basic security
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof Configuration
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* parameter for oauth2 security
|
||||
* @param name security name
|
||||
* @param scopes oauth2 scope
|
||||
* @memberof Configuration
|
||||
*/
|
||||
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
|
||||
/**
|
||||
* override base path
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof Configuration
|
||||
*/
|
||||
basePath?: string;
|
||||
/**
|
||||
* base options for axios calls
|
||||
*
|
||||
* @type {any}
|
||||
* @memberof Configuration
|
||||
*/
|
||||
baseOptions?: any;
|
||||
/**
|
||||
* The FormData constructor that will be used to create multipart form data
|
||||
* requests. You can inject this here so that execution environments that
|
||||
* do not support the FormData class can still run the generated client.
|
||||
*
|
||||
* @type {new () => FormData}
|
||||
*/
|
||||
formDataCtor?: new () => any;
|
||||
|
||||
constructor(param: ConfigurationParameters = {}) {
|
||||
this.apiKey = param.apiKey;
|
||||
this.username = param.username;
|
||||
this.password = param.password;
|
||||
this.accessToken = param.accessToken;
|
||||
this.basePath = param.basePath;
|
||||
this.baseOptions = param.baseOptions;
|
||||
this.formDataCtor = param.formDataCtor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given MIME is a JSON MIME.
|
||||
* JSON MIME examples:
|
||||
* application/json
|
||||
* application/json; charset=UTF8
|
||||
* APPLICATION/JSON
|
||||
* application/vnd.company+json
|
||||
* @param mime - MIME (Multipurpose Internet Mail Extensions)
|
||||
* @return True if the given MIME is JSON, false otherwise.
|
||||
*/
|
||||
public isJsonMime(mime: string): boolean {
|
||||
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
|
||||
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
*
|
||||
* API for the catalyst incident response platform.
|
||||
*
|
||||
* The version of the OpenAPI document:
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
export * from "./api";
|
||||
export * from "./configuration";
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex" >
|
||||
<v-spacer></v-spacer>
|
||||
<v-switch
|
||||
id="advanced"
|
||||
v-model="advanced"
|
||||
label="Advanced"
|
||||
class="float-right mt-0"
|
||||
></v-switch>
|
||||
</div>
|
||||
<div class="d-flex" >
|
||||
<v-spacer></v-spacer>
|
||||
<span v-if="advanced" class="float-right">
|
||||
See
|
||||
<a target="_blank" href="https://koumoul-dev.github.io/vuetify-jsonschema-form/latest/">
|
||||
vuetify-jsonschema documentation
|
||||
</a>
|
||||
for styling.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<v-row class="flex-grow-0 flex-shrink-0">
|
||||
<v-col :cols="hidepreview ? 12 : 7">
|
||||
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">
|
||||
Schema
|
||||
</v-subheader>
|
||||
<div v-if="!advanced">
|
||||
<json-schema-editor :disabled="readonly" :value="{ root: internalSchema }" lang="en_US" style="border: 1px solid #393a3f" class="mb-3 rounded" />
|
||||
</div>
|
||||
<div v-else class="flex-grow-1 flex-shrink-1 overflow-scroll">
|
||||
<Editor v-model="schemaString" lang="json" :readonly="readonly"></Editor>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col v-if="!hidepreview" cols="5">
|
||||
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">
|
||||
Form output preview
|
||||
</v-subheader>
|
||||
<v-form v-model="valid">
|
||||
<v-jsf
|
||||
v-model="details"
|
||||
:schema="advanced ? parsedSchemaString : internalSchema"
|
||||
:options="{ readonly: true, formats: { time: timeformat, date: dateformat, 'date-time': datetimeformat } }"
|
||||
/>
|
||||
</v-form>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row v-if="!readonly" class="px-3 my-6 flex-grow-0 flex-shrink-0">
|
||||
<v-btn v-if="this.$route.params.id === 'new'" color="success" @click="save" outlined>
|
||||
<v-icon>mdi-plus-thick</v-icon>
|
||||
Create
|
||||
</v-btn>
|
||||
<v-btn v-else color="success" @click="save" outlined>
|
||||
<v-icon>mdi-content-save</v-icon>
|
||||
Save
|
||||
</v-btn>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Editor from "./Editor.vue";
|
||||
import Vue from "vue";
|
||||
import {DateTime} from "luxon";
|
||||
|
||||
interface State {
|
||||
advanced: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
internalSchema: any;
|
||||
schemaString: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
details: any;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: "AdvancedJSONSchemaEditor",
|
||||
components: { Editor },
|
||||
props: {
|
||||
schema: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
hidepreview: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data: (): State => ({
|
||||
details: {},
|
||||
advanced: false,
|
||||
internalSchema: {},
|
||||
schemaString: "{}",
|
||||
valid: true,
|
||||
}),
|
||||
watch: {
|
||||
schema: function () {
|
||||
this.internalSchema = this.schema;
|
||||
this.schemaString = JSON.stringify(this.internalSchema, null, 2);
|
||||
},
|
||||
advanced: function (advanced) {
|
||||
if (advanced) {
|
||||
this.schemaString = JSON.stringify(this.internalSchema, null, 2);
|
||||
} else {
|
||||
this.internalSchema = JSON.parse(this.schemaString);
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
parsedSchemaString: function() {
|
||||
try {
|
||||
return JSON.parse(this.schemaString);
|
||||
}
|
||||
catch (e) {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
save: function () {
|
||||
let schema = this.schemaString;
|
||||
if (!this.advanced) {
|
||||
schema = JSON.stringify(this.internalSchema);
|
||||
}
|
||||
this.$emit("save", schema);
|
||||
},
|
||||
timeformat: function(s: string, locale: string) {
|
||||
let format = this.$store.state.settings.timeformat;
|
||||
if (!format) {
|
||||
return DateTime.fromISO(s).toLocaleString(DateTime.DATETIME_SHORT);
|
||||
}
|
||||
return DateTime.fromISO(s).toFormat(format);
|
||||
},
|
||||
dateformat: function(s: string, locale: string) {
|
||||
let format = this.$store.state.settings.timeformat;
|
||||
if (!format) {
|
||||
return DateTime.fromISO(s).toLocaleString(DateTime.DATETIME_SHORT);
|
||||
}
|
||||
return DateTime.fromISO(s).toFormat(format);
|
||||
},
|
||||
datetimeformat: function(s: string, locale: string) {
|
||||
let format = this.$store.state.settings.timeformat;
|
||||
if (!format) {
|
||||
return DateTime.fromISO(s).toLocaleString(DateTime.DATETIME_SHORT);
|
||||
}
|
||||
return DateTime.fromISO(s).toFormat(format);
|
||||
},
|
||||
hasRole: function (s: string): boolean {
|
||||
if (this.$store.state.settings.roles) {
|
||||
return this.lodash.includes(this.$store.state.settings.roles, s);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.internalSchema = this.schema;
|
||||
this.schemaString = JSON.stringify(this.internalSchema, null, 2);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.theme--dark .ant-btn,
|
||||
.theme--dark .ant-select-selection,
|
||||
.theme--dark .ant-input,
|
||||
.theme--dark .ant-input-number,
|
||||
.theme--dark .ant-modal-header,
|
||||
.theme--dark .ant-modal-title,
|
||||
.theme--dark .ant-form-item,
|
||||
.theme--dark .ant-modal-close-x,
|
||||
.theme--dark .ant-select-dropdown,
|
||||
.theme--dark .ant-checkbox-inner {
|
||||
color: white !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.theme--dark .ant-select-selection,
|
||||
.theme--dark .ant-input,
|
||||
.theme--dark .ant-input-number,
|
||||
.theme--dark .ant-modal-header,
|
||||
.theme--dark .ant-modal-footer,
|
||||
.theme--dark .ant-checkbox-inner {
|
||||
border-color: #424242 !important;
|
||||
}
|
||||
|
||||
.theme--dark .ant-modal-content,
|
||||
.theme--dark .ant-select-dropdown {
|
||||
color: white !important;
|
||||
background: #303030 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,64 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-list nav dense>
|
||||
<v-list-item v-for="link in userLinks" :key="link.to" link :to="{ name: link.to }">
|
||||
<v-list-item-icon>
|
||||
<v-badge
|
||||
v-if="'count' in link && link.count"
|
||||
:content="link.count"
|
||||
color="red"
|
||||
left
|
||||
offset-x="35"
|
||||
offset-y="8"
|
||||
bottom>
|
||||
<v-icon>{{ link.icon }}</v-icon>
|
||||
</v-badge>
|
||||
<v-icon v-else>{{ link.icon }}</v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-title>{{ link.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-divider v-if="userLinks.length > 0"></v-divider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
|
||||
export default Vue.extend({
|
||||
name: "AppLink",
|
||||
props: ["links"],
|
||||
computed: {
|
||||
userLinks: function (): Array<any> {
|
||||
return this.lodash.filter(this.links, link => {
|
||||
return this.hasRole(link) && this.hasTier(link)
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasRole: function (link: any) {
|
||||
if (!("role" in link)) {
|
||||
return true;
|
||||
}
|
||||
let has = false;
|
||||
if (this.$store.state.settings.roles) {
|
||||
this.lodash.forEach(this.$store.state.settings.roles, (userRole) => {
|
||||
if (link.role === userRole || this.lodash.startsWith(link.role, userRole + ":")) {
|
||||
has = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
return has;
|
||||
},
|
||||
hasTier: function (link: any): boolean {
|
||||
if ("tier" in link) {
|
||||
if (this.$store.state.settings.tier) {
|
||||
return this.$store.state.settings.tier == link.tier;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<prism-editor
|
||||
v-if="showEditor"
|
||||
class="editor"
|
||||
v-model="code"
|
||||
:highlight="highlighter"
|
||||
line-numbers
|
||||
:readonly="readonly">
|
||||
</prism-editor>
|
||||
|
||||
<!--MonacoEditor
|
||||
v-if="showEditor"
|
||||
ref="editor"
|
||||
class="editor"
|
||||
style="height: 100%"
|
||||
v-model="code"
|
||||
:language="this.lang"
|
||||
:options="{ scrollBeyondLastLine: false }"
|
||||
:theme="$vuetify.theme.dark ? 'vs-dark' : 'vs'"
|
||||
/-->
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
// import MonacoEditor from "vue-monaco";
|
||||
|
||||
import { PrismEditor } from 'vue-prism-editor';
|
||||
import 'vue-prism-editor/dist/prismeditor.min.css'; // import the styles somewhere
|
||||
|
||||
import { highlight, languages } from 'prismjs/components/prism-core';
|
||||
// import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-log';
|
||||
import 'prismjs/themes/prism-tomorrow.css'; // import syntax highlighting styles
|
||||
|
||||
interface State {
|
||||
showEditor: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resize?: any;
|
||||
code: string,
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: "Editor",
|
||||
props: ["value", "lang", "readonly"],
|
||||
components: {PrismEditor},
|
||||
data: (): State => ({
|
||||
showEditor: true,
|
||||
resize: undefined,
|
||||
code: "",
|
||||
}),
|
||||
watch: {
|
||||
code: function () {
|
||||
this.$emit('input', this.code);
|
||||
},
|
||||
value: function () {
|
||||
this.code = this.value;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// resizeEditor() {
|
||||
// this.showEditor = false;
|
||||
// this.$nextTick(() => {
|
||||
// this.showEditor = true;
|
||||
// });
|
||||
// },
|
||||
highlighter(code: string) {
|
||||
switch (this.lang) {
|
||||
case "python":
|
||||
return highlight(code, languages.python);
|
||||
case "log":
|
||||
return highlight(code, languages.log);
|
||||
case "yaml":
|
||||
return highlight(code, languages.yaml);
|
||||
case "json":
|
||||
return highlight(code, languages.json);
|
||||
case "html":
|
||||
return highlight(code, languages.html);
|
||||
}
|
||||
return highlight(code, languages.json);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.code = this.value;
|
||||
// this.resize = this.lodash.debounce(this.resizeEditor, 200);
|
||||
// window.addTicketListener("resize", this.resize);
|
||||
},
|
||||
destroyed() {
|
||||
// window.removeticketListener("resize", this.resize);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* required class */
|
||||
.my-editor {
|
||||
/* we dont use `language-` classes anymore so thats why we need to add background and text color manually */
|
||||
background: #2d2d2d;
|
||||
color: #ccc;
|
||||
|
||||
/* you must provide font-family font-size line-height. Example: */
|
||||
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
/* optional class for removing the outline */
|
||||
.prism-editor__textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
34
ui/src/components/Icon.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" setup>
|
||||
import * as icons from 'lucide-vue-next'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
size: Number,
|
||||
color: String,
|
||||
strokeWidth: Number,
|
||||
defaultClass: String
|
||||
})
|
||||
|
||||
const icon = computed((): string => {
|
||||
if (!icons[props.name]) {
|
||||
return 'Flame'
|
||||
}
|
||||
|
||||
return icons[props.name]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="icon"
|
||||
:size="size"
|
||||
:color="color"
|
||||
:stroke-width="strokeWidth"
|
||||
:default-class="defaultClass"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="(value, key) in json" :key="key">
|
||||
<dl v-if="typeof(value) == 'object'">
|
||||
<dt>{{ key }}</dt>
|
||||
<dd><JSONHTML :json="value"></JSONHTML></dd>
|
||||
</dl>
|
||||
<span v-else>
|
||||
<b>{{ key }}</b>: {{ value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "JSONHTML",
|
||||
props: ["json"]
|
||||
}
|
||||
</script>
|
||||
@@ -1,153 +0,0 @@
|
||||
<template>
|
||||
<v-row class="fill-height ma-0">
|
||||
<v-col cols="3" class="listnav" style="">
|
||||
<v-list nav color="background">
|
||||
<v-list-item
|
||||
v-if="showNew && canWrite"
|
||||
:to="{ name: routername, params: { id: 'new' } }"
|
||||
class="mt-4 mx-4 text-center newbutton"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
<v-icon small>mdi-plus</v-icon> New {{ singular }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-subheader class="pl-4">{{ plural }}</v-subheader>
|
||||
<v-list-item
|
||||
v-for="item in (items ? items : [])"
|
||||
:key="item[itemid]"
|
||||
link
|
||||
:to="{ name: routername, params: { id: item[itemid] } }"
|
||||
class="mx-2"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
{{ item[itemname] }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
|
||||
<v-list-item-action v-if="deletable && canWrite">
|
||||
<v-icon @click="askDelete(item[itemid])" class="fader">
|
||||
mdi-close
|
||||
</v-icon>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-col>
|
||||
<v-col cols="9">
|
||||
<router-view></router-view>
|
||||
</v-col>
|
||||
<v-dialog v-model="dialog" persistent max-width="400">
|
||||
<v-card>
|
||||
<v-card-title> Delete {{ singular }} {{ deleteName }} ? </v-card-title>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn id="cancel-button" color="error" text @click="dialog = false">Cancel</v-btn>
|
||||
<v-btn id="delete-button" color="success" outlined @click="deleteItem(deleteName)">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
|
||||
interface State {
|
||||
dialog: boolean;
|
||||
deleteName?: string;
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: "List",
|
||||
components: { },
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
routername: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
itemid: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
itemname: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
singular: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
plural: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showNew: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
deletable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
writepermission: {
|
||||
type: String,
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
data: (): State => ({
|
||||
dialog: false,
|
||||
deleteName: undefined,
|
||||
}),
|
||||
computed: {
|
||||
canWrite: function (): boolean {
|
||||
return this.hasRole(this.writepermission);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
askDelete(name: string) {
|
||||
this.deleteName = name;
|
||||
this.dialog = true;
|
||||
},
|
||||
deleteItem(deleteName: string) {
|
||||
this.$emit('delete', deleteName);
|
||||
this.dialog = false;
|
||||
},
|
||||
hasRole: function (s: string): boolean {
|
||||
if (this.$store.state.settings.roles) {
|
||||
return this.lodash.includes(this.$store.state.settings.roles, s);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.listnav {
|
||||
border-right: 1px solid #e0e0e0;
|
||||
}
|
||||
.theme--dark .listnav {
|
||||
border-right: 1px solid #393a3f;
|
||||
}
|
||||
|
||||
.newbutton {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.theme--dark .newbutton {
|
||||
background: #424242;
|
||||
}
|
||||
|
||||
.v-list-item .fader {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.v-list-item:hover .fader {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
</style>
|
||||
15
ui/src/components/ShortCut.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
keys: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<kbd
|
||||
class="pointer-events-none ml-2 inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100"
|
||||
>
|
||||
<span class="text-xs">
|
||||
{{ keys }}
|
||||
</span>
|
||||
</kbd>
|
||||
</template>
|
||||
23
ui/src/components/TanView.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
|
||||
import { LoaderCircle } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
isPending: boolean
|
||||
isError: boolean
|
||||
error: Error | null
|
||||
value: any
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isPending" class="flex justify-center">
|
||||
<LoaderCircle class="h-16 w-16 animate-spin text-primary" />
|
||||
</div>
|
||||
<Alert v-else-if="isError" variant="destructive" class="mb-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
<slot v-else-if="value" />
|
||||
</template>
|
||||
@@ -1,364 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-row v-if="selectedtype" class="mx-0 my-2" dense>
|
||||
<v-col :cols="this.$route.params.id ? 4 : 2">
|
||||
<v-select
|
||||
v-model="selectedtype"
|
||||
:items="tickettypes"
|
||||
item-text="name"
|
||||
item-value="id"
|
||||
solo
|
||||
rounded
|
||||
label="Type"
|
||||
dense
|
||||
flat
|
||||
hide-details
|
||||
height="48px"></v-select>
|
||||
</v-col>
|
||||
<v-col :cols="this.$route.params.id ? 8 : 10">
|
||||
<v-btn elevation="0" rounded class="float-right mb-2" @click="opennew({ name: 'Ticket', params: { type: selectedtype, id: 'new' } })">
|
||||
<v-icon class="mr-1">mdi-plus</v-icon>
|
||||
New {{ selectedtype | capitalize }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-toolbar
|
||||
id="caqlbar"
|
||||
rounded
|
||||
filled
|
||||
dense
|
||||
flat
|
||||
elevation="0"
|
||||
style="border-radius: 40px !important;"
|
||||
>
|
||||
<v-btn-toggle dense v-model="defaultcaql">
|
||||
<v-btn
|
||||
text
|
||||
color="primary"
|
||||
@click="caql = !caql"
|
||||
rounded
|
||||
style="border-radius: 40px !important;">
|
||||
CAQL
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
||||
<v-text-field
|
||||
placeholder="Search term or query (e.g. name == 'malware' AND 'wannacry')"
|
||||
v-model="term"
|
||||
dense
|
||||
solo
|
||||
flat
|
||||
hide-details
|
||||
clearable
|
||||
append-icon="mdi-magnify"
|
||||
@click:clear="clear"
|
||||
@click:append="loadTickets"
|
||||
@keydown.enter="loadTickets"
|
||||
:rules="[validate]"
|
||||
@focus="focus = true"
|
||||
@blur="blur"
|
||||
></v-text-field>
|
||||
</v-toolbar>
|
||||
|
||||
<span v-if="focus && caql">CAQL Query Suggestions</span>
|
||||
<v-list class="mb-2" v-if="focus && caql">
|
||||
<v-list-item v-for="example in examples" :key="example.q" dense link @click="term = example.q; caql = true; defaultcaql = 0; loadTickets()">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>
|
||||
<v-row>
|
||||
<span class="col-6">{{ example.q }}</span> <span class="text--disabled col-6">{{ example.desc }}</span>
|
||||
</v-row>
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-content>
|
||||
Fields: {{ lodash.join(fields, ", ") }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="tickets"
|
||||
item-key="name"
|
||||
multi-sort
|
||||
class="elevation-0 cards clickable mt-2"
|
||||
:options.sync="options"
|
||||
:server-items-length="totalTickets"
|
||||
:loading="loading"
|
||||
:footer-props="{ 'items-per-page-options': [10, 25, 50, 100] }"
|
||||
>
|
||||
<template v-slot:item="{ item }">
|
||||
<tr @click="open(item)">
|
||||
<td colspan="5" class="pa-0">
|
||||
<v-list-item class="pa-0" style="background: none">
|
||||
<ticketSnippet :ticket="item"></ticketSnippet>
|
||||
</v-list-item>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
import { Ticket, TicketType } from "@/client";
|
||||
import { API } from "@/services/api";
|
||||
import TicketSnippet from "../components/snippets/TicketSnippet.vue";
|
||||
import {validateCAQL} from "@/suggestions/suggestions";
|
||||
import {DateTime} from "luxon";
|
||||
|
||||
interface State {
|
||||
term: string;
|
||||
loading: boolean;
|
||||
tickets: Array<Ticket>;
|
||||
totalTickets: number;
|
||||
options: {
|
||||
page?: number;
|
||||
itemsPerPage?: number;
|
||||
sortBy?: string[];
|
||||
sortDesc?: boolean[];
|
||||
groupBy?: string[];
|
||||
groupDesc?: boolean[];
|
||||
multiSort?: boolean;
|
||||
mustSort?: boolean;
|
||||
};
|
||||
focus: boolean;
|
||||
caql: boolean;
|
||||
defaultcaql?: number;
|
||||
|
||||
tickettypes: Array<TicketType>;
|
||||
selectedtype: string;
|
||||
}
|
||||
|
||||
interface QuerySuggestion {
|
||||
q: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: "TicketList",
|
||||
components: {
|
||||
TicketSnippet,
|
||||
},
|
||||
props: ["type", "query"],
|
||||
data: (): State => ({
|
||||
term: "status == 'open'",
|
||||
loading: true,
|
||||
tickets: [],
|
||||
totalTickets: 0,
|
||||
options: {
|
||||
itemsPerPage: 10,
|
||||
},
|
||||
focus: false,
|
||||
caql: true,
|
||||
defaultcaql: 0,
|
||||
tickettypes: [],
|
||||
|
||||
selectedtype: "",
|
||||
}),
|
||||
computed: {
|
||||
fields(): Array<string> {
|
||||
return [
|
||||
"type", "id", "name",
|
||||
"status" , "owner",
|
||||
"created", "modified",
|
||||
"details", "details.description", "details.…",
|
||||
"schema",
|
||||
"comments", "comments.#.created", "comments.#.creator", "comments.#.message",
|
||||
"playbooks", "playbooks.#.name", "playbooks.#.tasks",
|
||||
"references", "references.#.href", "references.#.name",
|
||||
"artifacts", "artifacts.#.name", "artifacts.#.status", "artifacts.#.type",
|
||||
"files", "files.#.name"
|
||||
];
|
||||
},
|
||||
user(): string {
|
||||
return this.$store.state.user.id
|
||||
},
|
||||
headers() {
|
||||
return [
|
||||
{
|
||||
text: "Name",
|
||||
align: "start",
|
||||
value: "name",
|
||||
},
|
||||
{
|
||||
text: "Status",
|
||||
align: "start",
|
||||
value: "status",
|
||||
},
|
||||
{
|
||||
text: "Owner",
|
||||
align: "start",
|
||||
value: "owner",
|
||||
},
|
||||
{
|
||||
text: "Creation",
|
||||
align: "start",
|
||||
value: "created",
|
||||
},
|
||||
{
|
||||
text: "Last Modification",
|
||||
align: "start",
|
||||
value: "modified",
|
||||
},
|
||||
];
|
||||
},
|
||||
examples (): Array<QuerySuggestion> {
|
||||
let twoWeeksAgo = DateTime.utc().minus({weeks: 2}).toFormat("yyyy-MM-dd");
|
||||
|
||||
let ex: Array<QuerySuggestion> = [];
|
||||
|
||||
if (this.user) {
|
||||
ex.push({q: "status == 'open' AND (owner == '" + this.user + "' OR !owner)", desc: "Select all open tickets by you and unassigned"})
|
||||
ex.push({q: "status == 'closed' AND owner == '" + this.user + "'", desc: "Select completed tickets by you"})
|
||||
} else {
|
||||
ex.push({q: "status == 'open'", desc: "Select all open tickets"})
|
||||
ex.push({q: "status == 'closed'", desc: "Select completed tickets"})
|
||||
}
|
||||
|
||||
ex.push({q: "created > \""+twoWeeksAgo+"\"", desc: "Select tickets created in the last two weeks"})
|
||||
|
||||
if (this.term && this.term.match(/^[A-Za-z ]+$/)) {
|
||||
ex.unshift({q: "'" + this.term + "'", desc: "Full text search for '" + this.term + "'"})
|
||||
}
|
||||
return ex;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
options: {
|
||||
handler() {
|
||||
this.loadTickets();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
selectedtype: function () {
|
||||
this.loadTickets();
|
||||
},
|
||||
$route: function () {
|
||||
this.selectedtype = this.type;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
blur: function () {
|
||||
setTimeout(()=>{this.focus = false}, 200)
|
||||
},
|
||||
clear: function () {
|
||||
this.caql = false;
|
||||
this.defaultcaql = undefined;
|
||||
this.term = "";
|
||||
this.loadTickets();
|
||||
},
|
||||
open: function (ticket: Ticket) {
|
||||
this.$emit("click", ticket);
|
||||
},
|
||||
opennew: function (to) {
|
||||
this.$router.push(to).then(() => {
|
||||
this.$emit("new");
|
||||
})
|
||||
},
|
||||
select: function (e: string) {
|
||||
this.loadTerm(e);
|
||||
},
|
||||
loadTickets() {
|
||||
let term = this.term;
|
||||
if (!term) {
|
||||
term = "";
|
||||
}
|
||||
this.loadTerm(term);
|
||||
},
|
||||
loadTicketTypes() {
|
||||
API.listTicketTypes().then((reponse) => {
|
||||
this.tickettypes = reponse.data;
|
||||
})
|
||||
},
|
||||
loadTerm(term: string) {
|
||||
this.loading = true;
|
||||
let offset = 0;
|
||||
let count = 25;
|
||||
let sortBy: Array<string> = [];
|
||||
let sortDesc: Array<boolean> = [];
|
||||
if (this.options.itemsPerPage !== undefined) {
|
||||
count = this.options.itemsPerPage;
|
||||
if (this.options.page !== undefined) {
|
||||
offset = (this.options.page - 1) * this.options.itemsPerPage;
|
||||
}
|
||||
}
|
||||
if (this.options.sortBy !== undefined) {
|
||||
sortBy = this.options.sortBy;
|
||||
}
|
||||
if (this.options.sortDesc !== undefined) {
|
||||
sortDesc = this.options.sortDesc;
|
||||
}
|
||||
|
||||
let ticketType = this.selectedtype;
|
||||
if (!ticketType) {
|
||||
ticketType = "";
|
||||
}
|
||||
|
||||
if (!this.caql && term.length > 0) {
|
||||
term = "'" + this.lodash.join(this.lodash.split(term, " "), "'&&'") + "'"
|
||||
}
|
||||
|
||||
API.listTickets(ticketType, offset, count, sortBy, sortDesc, term)
|
||||
.then((response) => {
|
||||
if (response.data.tickets) {
|
||||
this.tickets = response.data.tickets;
|
||||
} else {
|
||||
this.tickets = [];
|
||||
}
|
||||
this.totalTickets = response.data.count;
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
|
||||
},
|
||||
validate: function () {
|
||||
if (!this.term) {
|
||||
return true
|
||||
}
|
||||
let err = validateCAQL(this.term);
|
||||
if (err !== null) {
|
||||
return err.message;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.user) {
|
||||
this.term = "status == 'open' AND (owner == '" + this.user + "' OR !owner)";
|
||||
} else {
|
||||
this.term = "status == 'open'";
|
||||
}
|
||||
|
||||
if (this.query) {
|
||||
this.term = this.query;
|
||||
}
|
||||
|
||||
this.selectedtype = this.type;
|
||||
this.loadTicketTypes();
|
||||
this.loadTickets();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.clickable td {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.vue-simple-suggest.designed .input-wrapper input {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
color: #fff;
|
||||
}
|
||||
.vue-simple-suggest.designed .suggestions {
|
||||
background-color: #333 !important;
|
||||
top: 60px !important;
|
||||
border: 1px solid #ddd !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,139 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-textarea
|
||||
v-model="comment"
|
||||
hide-details
|
||||
flat
|
||||
label="Add a comment..."
|
||||
solo
|
||||
auto-grow
|
||||
rows="2"
|
||||
class="py-2"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<v-btn class="mx-0 mt-n1" text @click="addTicketLog">
|
||||
<v-icon>mdi-send</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-textarea>
|
||||
<div
|
||||
v-for="(log, id) in internalLogs"
|
||||
:key="id"
|
||||
:icon="icon(log.type)"
|
||||
:small="small(log.type)"
|
||||
class="pb-2"
|
||||
>
|
||||
<v-card v-if="log.type === 'comment'" elevation="0" color="cards">
|
||||
<v-card-subtitle class="pb-0">
|
||||
<strong> {{ log.creator }}</strong>
|
||||
<span class="text--disabled ml-3" :title="log.created">
|
||||
{{ relDate(log.created) }}
|
||||
</span>
|
||||
</v-card-subtitle>
|
||||
<v-card-text class="mb-0 mt-2">
|
||||
<!--{{ log.message }}-->
|
||||
<vue-markdown v-if="show">{{ log.message }}</vue-markdown>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<div v-else style="line-height: 24px" class="d-flex flex-row">
|
||||
<v-divider class="mt-3 mr-3"></v-divider>
|
||||
{{ log.message }}
|
||||
<span class="text--disabled ml-1" :title="log.created">
|
||||
·
|
||||
{{ log.creator }} ·
|
||||
{{ relDate(log.created) }}
|
||||
</span>
|
||||
<v-divider class="mt-3 ml-3"></v-divider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
import { LogEntry } from "@/client";
|
||||
import VueMarkdown from "vue-markdown";
|
||||
import { API } from "@/services/api";
|
||||
|
||||
interface State {
|
||||
comment: string
|
||||
internalLogs: Array<LogEntry>
|
||||
show: boolean
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: "Timeline",
|
||||
components: {
|
||||
"vue-markdown": VueMarkdown
|
||||
},
|
||||
props: ["id", "logs"],
|
||||
data: (): State => ({
|
||||
comment: "",
|
||||
internalLogs: [],
|
||||
show: true
|
||||
}),
|
||||
watch: {
|
||||
logs: function () {
|
||||
// this.internalLogs = this.logs;
|
||||
this.reload(this.logs);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
reload: function(newlogs: Array<LogEntry>) {
|
||||
if (newlogs === undefined) {
|
||||
return
|
||||
}
|
||||
this.show = false;
|
||||
Vue.nextTick(() => {
|
||||
this.internalLogs = newlogs;
|
||||
this.show = true;
|
||||
})
|
||||
},
|
||||
icon: function(s: string) {
|
||||
switch (s) {
|
||||
case "comment":
|
||||
return "mdi-comment";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
small: function(s: string) {
|
||||
switch (s) {
|
||||
case "comment":
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
relDate: function(date: string) {
|
||||
let rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
let deltaDays =
|
||||
(new Date(date).getTime() - new Date().getTime()) / (1000 * 3600 * 24);
|
||||
let relDate = rtf.format(Math.round(deltaDays), "days");
|
||||
if (deltaDays > -3) {
|
||||
relDate +=
|
||||
", " +
|
||||
new Date(date).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
}
|
||||
return relDate;
|
||||
},
|
||||
addTicketLog() {
|
||||
// API.addLog({ id: this.id, message: this.comment }).then(
|
||||
// response => {
|
||||
// this.$store.dispatch("alertSuccess", { name: "Log saved", type: "success" });
|
||||
// if (this.internalLogs === undefined) {
|
||||
// this.reload([response.data]);
|
||||
// } else {
|
||||
// this.internalLogs.unshift(response.data);
|
||||
// this.reload(this.internalLogs);
|
||||
// }
|
||||
// }
|
||||
// );
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.internalLogs = this.logs;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,63 +0,0 @@
|
||||
<template>
|
||||
<span>
|
||||
<span v-if="id === undefined">
|
||||
<v-icon small class="mr-1">mdi-account</v-icon>
|
||||
unassigned
|
||||
</span>
|
||||
<span v-else-if="user === undefined">
|
||||
<v-icon small class="mr-1">mdi-account</v-icon>
|
||||
{{ id }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<v-avatar v-if="user.image" :size="lodash.isInteger(size) ? size : 24">
|
||||
<v-img :src="user.image"></v-img>
|
||||
</v-avatar>
|
||||
<v-icon v-else small class="">mdi-account</v-icon>
|
||||
{{ user.name ? user.name : id }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
import {UserData} from "@/client";
|
||||
import {API} from "@/services/api";
|
||||
import {AxiosResponseTransformer} from "axios";
|
||||
|
||||
interface State {
|
||||
user?: UserData,
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: "User",
|
||||
props: ["id", "size"],
|
||||
data: (): State => ({
|
||||
user: undefined,
|
||||
}),
|
||||
watch: {
|
||||
id: function(): void {
|
||||
this.loadUserData();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadUserData: function () {
|
||||
if (this.id === undefined) {
|
||||
this.user = undefined;
|
||||
return
|
||||
}
|
||||
|
||||
let defaultTransformers = this.axios.defaults.transformResponse as AxiosResponseTransformer[]
|
||||
let transformResponse = defaultTransformers.concat((data) => {
|
||||
data.notoast = true;
|
||||
return data
|
||||
});
|
||||
API.getUserData(this.id, {transformResponse: transformResponse}).then(response => {
|
||||
this.user = response.data;
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadUserData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,184 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="editoruserdata === undefined" class="text-sm-center py-16">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
:size="70"
|
||||
:width="7"
|
||||
class="align-center"
|
||||
>
|
||||
</v-progress-circular>
|
||||
</div>
|
||||
<div v-else class="pa-8">
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="4" class="d-flex flex-column align-center">
|
||||
<v-avatar v-if="editoruserdata.image" size="128" class="mt-1">
|
||||
<img :src="editoruserdata.image" alt="userdata avatar" />
|
||||
</v-avatar>
|
||||
|
||||
<v-file-input
|
||||
v-model="file"
|
||||
type="file"
|
||||
class="pt-2 flex-grow-0"
|
||||
style="width: 100%"
|
||||
accept="image/png, image/jpeg"
|
||||
label="Select Image"
|
||||
@change="change"
|
||||
:clearable="false"
|
||||
>
|
||||
<template v-slot:append-outer>
|
||||
<v-btn
|
||||
v-if="showCrop"
|
||||
rounded
|
||||
small
|
||||
color="accent"
|
||||
@click="validate"
|
||||
>
|
||||
<v-icon>
|
||||
mdi-check
|
||||
</v-icon>
|
||||
Set
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!!editoruserdata.image"
|
||||
small
|
||||
rounded
|
||||
color="error"
|
||||
@click="
|
||||
file = null;
|
||||
editoruserdata.image = '';
|
||||
showCrop = false;
|
||||
"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-icon>
|
||||
mdi-close
|
||||
</v-icon>
|
||||
Clear
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-file-input>
|
||||
|
||||
<vue-cropper
|
||||
v-if="showCrop"
|
||||
ref="cropper"
|
||||
v-bind="{ aspectRatio: 1, autoCrop: true }"
|
||||
:src="imgSrc"
|
||||
alt="Avatar"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="8">
|
||||
<v-text-field
|
||||
prepend-icon="mdi-account"
|
||||
label="Name"
|
||||
v-model="editoruserdata.name"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
prepend-icon="mdi-email"
|
||||
label="Email"
|
||||
v-model="editoruserdata.email"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
prepend-icon="mdi-timetable"
|
||||
label="Timeformat"
|
||||
v-model="editoruserdata.timeformat"
|
||||
></v-text-field>
|
||||
|
||||
<v-btn
|
||||
color="success"
|
||||
outlined
|
||||
@click="saveUserData"
|
||||
class="mt-6"
|
||||
>
|
||||
<v-icon>mdi-content-save</v-icon>
|
||||
Save
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
import { UserData } from "@/client";
|
||||
import VueCropper from "vue-cropperjs";
|
||||
import "cropperjs/dist/cropper.css";
|
||||
|
||||
interface State {
|
||||
tab: number;
|
||||
editoruserdata?: UserData;
|
||||
file: File | null;
|
||||
imgSrc: string | ArrayBuffer | null;
|
||||
showCrop: boolean;
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: "UserDataEditor",
|
||||
data: (): State => ({
|
||||
tab: 0,
|
||||
editoruserdata: undefined,
|
||||
file: null,
|
||||
imgSrc: null,
|
||||
showCrop: false
|
||||
}),
|
||||
props: ['userdata'],
|
||||
components: {
|
||||
VueCropper
|
||||
},
|
||||
watch: {
|
||||
"userdata": function () {
|
||||
this.editoruserdata = this.userdata;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveUserData: function() {
|
||||
this.$emit("save", this.editoruserdata);
|
||||
},
|
||||
change: function() {
|
||||
if (!this.file) {
|
||||
this.imgSrc = null;
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = ticket => {
|
||||
if (ticket.target && this.$refs.cropper) {
|
||||
this.imgSrc = ticket.target.result;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let cropper: any = this.$refs.cropper;
|
||||
cropper.replace(this.imgSrc);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(this.file);
|
||||
this.showCrop = true;
|
||||
},
|
||||
validate: function() {
|
||||
if (this.$refs.cropper && this.editoruserdata) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let cropper: any = this.$refs.cropper;
|
||||
this.editoruserdata.image = cropper
|
||||
.getCroppedCanvas({width: 128, height: 128})
|
||||
.toDataURL("image/png");
|
||||
// this.on.input(croppedImg)
|
||||
this.showCrop = false;
|
||||
// this.file = null
|
||||
// this.imgSrc = null
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.editoruserdata = this.userdata;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.theme--dark.v-tabs-items,
|
||||
.theme--dark.v-tabs > .v-tabs-bar {
|
||||
background: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<v-input :value="value" class="vjsf-crop-img">
|
||||
<v-row class="mt-0 mx-0" align="center">
|
||||
<v-avatar v-if="value" size="128" class="mt-1">
|
||||
<img :src="value">
|
||||
</v-avatar>
|
||||
<v-file-input
|
||||
v-model="file"
|
||||
type="file"
|
||||
class="pt-2"
|
||||
accept="image/png, image/jpeg"
|
||||
placeholder="User Avatar"
|
||||
@change="change"
|
||||
:clearable="false"
|
||||
>
|
||||
<template v-slot:append-outer>
|
||||
<v-btn v-if="imgSrc" fab x-small color="accent" @click="validate">
|
||||
<v-icon>
|
||||
mdi-check
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-file-input>
|
||||
<v-icon v-if="!!value" @click="on.input(null)">
|
||||
mdi-close
|
||||
</v-icon>
|
||||
</v-row>
|
||||
<vue-cropper
|
||||
v-if="file"
|
||||
ref="cropper"
|
||||
v-bind="cropperOptions"
|
||||
:src="imgSrc"
|
||||
alt="Avatar"
|
||||
/>
|
||||
</v-input>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import VueCropper from 'vue-cropperjs'
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
|
||||
export default {
|
||||
components: { VueCropper },
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
on: { type: Object, required: true },
|
||||
cropperOptions: { type: Object, default: () => ({ aspectRatio: 1, autoCrop: true }) },
|
||||
size: { type: Number, default: 128 } // same as default v-avatar size
|
||||
},
|
||||
data: () => ({
|
||||
file: null,
|
||||
imgSrc: null
|
||||
}),
|
||||
computed: {},
|
||||
methods: {
|
||||
change() {
|
||||
if (!this.file) {
|
||||
this.imgSrc = null
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ticket) => {
|
||||
this.imgSrc = ticket.target.result
|
||||
this.dialog = true
|
||||
this.$refs.cropper.replace(this.imgSrc)
|
||||
}
|
||||
reader.readAsDataURL(this.file)
|
||||
},
|
||||
async validate() {
|
||||
const croppedImg = this.$refs.cropper
|
||||
.getCroppedCanvas({ width: this.size, height: this.size })
|
||||
.toDataURL('image/png')
|
||||
this.on.input(croppedImg)
|
||||
this.file = null
|
||||
this.imgSrc = null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="css">
|
||||
.vjsf-crop-img>.v-input__control>.v-input__slot {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Component, Mixins } from 'vue-property-decorator'
|
||||
import {HorizontalBar, mixins} from 'vue-chartjs';
|
||||
import ChartOptions from "chart.js";
|
||||
|
||||
@Component({
|
||||
extends: HorizontalBar,
|
||||
mixins: [mixins.reactiveProp],
|
||||
props: {
|
||||
chartOptions: {
|
||||
type: ChartOptions,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
})
|
||||
export default class BarChart extends Mixins(mixins.reactiveProp, HorizontalBar) {
|
||||
mounted () {
|
||||
// @ts-expect-error chartOptions are not expected
|
||||
this.renderChart(this.chartData, this.chartOptions);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Component, Mixins } from "vue-property-decorator";
|
||||
import { mixins, Pie } from "vue-chartjs";
|
||||
import ChartOptions from "chart.js";
|
||||
|
||||
@Component({
|
||||
extends: Pie,
|
||||
mixins: [mixins.reactiveProp],
|
||||
props: {
|
||||
chartOptions: {
|
||||
type: ChartOptions,
|
||||
default: null
|
||||
}
|
||||
}
|
||||
})
|
||||
export default class DoughnutChart extends Mixins(mixins.reactiveProp, Pie) {
|
||||
mounted() {
|
||||
// @ts-expect-error chartOptions are not expected
|
||||
this.renderChart(this.chartData, this.chartOptions);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Component, Mixins } from 'vue-property-decorator'
|
||||
import {Line, mixins} from 'vue-chartjs';
|
||||
import ChartOptions from "chart.js";
|
||||
|
||||
@Component({
|
||||
extends: Line,
|
||||
mixins: [mixins.reactiveProp],
|
||||
props: {
|
||||
chartOptions: {
|
||||
type: ChartOptions,
|
||||
default: null,
|
||||
}
|
||||
},
|
||||
})
|
||||
export default class LineChart extends Mixins(mixins.reactiveProp, Line) {
|
||||
mounted () {
|
||||
// @ts-expect-error chartOptions are not expected
|
||||
this.renderChart(this.chartData, this.chartOptions);
|
||||
}
|
||||
}
|
||||
22
ui/src/components/common/PanelListElement.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full items-center border-t px-2 py-1 first:rounded-t first:border-none last:rounded-b',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
49
ui/src/components/common/ResourceListElement.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle: string
|
||||
description: string
|
||||
created: string
|
||||
|
||||
open: boolean
|
||||
active: boolean
|
||||
to: string | { name: string; params: Record<string, string | number> }
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col items-start gap-2 rounded-lg border bg-card p-3 text-left text-sm transition-all hover:bg-accent',
|
||||
active && 'bg-accent'
|
||||
)
|
||||
"
|
||||
:to="to"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-1">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-semibold">
|
||||
{{ title }}
|
||||
</div>
|
||||
<span v-if="open" class="flex h-2 w-2 rounded-full bg-blue-600" />
|
||||
</div>
|
||||
<div :class="cn('ml-auto text-xs', active ? 'text-foreground' : 'text-muted-foreground')">
|
||||
{{ formatDistanceToNow(new Date(created), { addSuffix: true }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="subtitle" class="text-xs font-medium">
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="description" class="line-clamp-2 text-xs text-muted-foreground">
|
||||
{{ description }}
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
23
ui/src/components/common/UserSelect.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import UserSelectList from '@/components/common/UserSelectList.vue'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { User } from '@/lib/types'
|
||||
|
||||
const user = defineModel<User>()
|
||||
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[150px] p-0">
|
||||
<UserSelectList v-model="user" :key="user ? user.id : 'unassigned'" :user="user" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
72
ui/src/components/common/UserSelectList.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
|
||||
import { Check } from 'lucide-vue-next'
|
||||
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import debounce from 'lodash.debounce'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { User } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const user = defineModel<User>()
|
||||
|
||||
const open = ref(false)
|
||||
const searchTerm = ref('')
|
||||
|
||||
const {
|
||||
isPending: usersIsPending,
|
||||
isError: usersIsError,
|
||||
data: users,
|
||||
error: usersError,
|
||||
refetch
|
||||
} = useQuery({
|
||||
queryKey: ['users', 'search', searchTerm.value],
|
||||
queryFn: () =>
|
||||
pb.collection('users').getFullList({
|
||||
sort: 'name',
|
||||
perPage: 5,
|
||||
filter: pb.filter(`name ~ {:search}`, { search: searchTerm.value })
|
||||
})
|
||||
})
|
||||
|
||||
const searchUserDebounced = debounce(() => refetch(), 300)
|
||||
|
||||
watch(searchTerm, () => searchUserDebounced())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Command v-model="user" v-model:search-term="searchTerm">
|
||||
<CommandInput placeholder="Search user..." />
|
||||
<CommandEmpty>
|
||||
<span v-if="usersIsPending"> Loading... </span>
|
||||
<span v-else-if="usersIsError"> Error: {{ usersError }} </span>
|
||||
<span>No user found.</span>
|
||||
</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="u in users"
|
||||
:key="u.id"
|
||||
:value="u"
|
||||
@select="open = false"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
:class="cn('mr-2 h-4 w-4', user && user.id === u.id ? 'opacity-100' : 'opacity-0')"
|
||||
/>
|
||||
{{ u.name }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</template>
|
||||
55
ui/src/components/dashboard/OpenTasks.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import PanelListElement from '@/components/common/PanelListElement.vue'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Task } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const {
|
||||
isPending,
|
||||
isError,
|
||||
data: tasks,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: (): Promise<Array<Task>> => {
|
||||
if (!pb.authStore.model) return Promise.reject('Not authenticated')
|
||||
return pb.collection('tasks').getFullList({
|
||||
sort: '-created',
|
||||
filter: pb.filter(`open = true && owner = {:owner}`, { owner: pb.authStore.model.id }),
|
||||
expand: 'owner,ticket'
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Card>
|
||||
<div v-if="tasks && tasks.length === 0" class="p-2 text-center text-sm text-gray-500">
|
||||
No open tasks
|
||||
</div>
|
||||
<PanelListElement v-else v-for="task in tasks" :key="task.id" class="pr-1">
|
||||
<span>{{ task.name }}</span>
|
||||
<RouterLink
|
||||
:to="{
|
||||
name: 'tickets',
|
||||
params: { type: task.expand.ticket.type, id: task.expand.ticket.id }
|
||||
}"
|
||||
:class="cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'ml-auto h-8')"
|
||||
>
|
||||
<span class="flex flex-row items-center text-sm text-gray-500">
|
||||
Go to {{ task.expand.ticket.name }}
|
||||
<ChevronRight class="ml-2 h-4 w-4" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</PanelListElement>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
66
ui/src/components/dashboard/OpenTickets.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import PanelListElement from '@/components/common/PanelListElement.vue'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { intervalToDuration } from 'date-fns'
|
||||
import format from 'date-fns/format'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Ticket } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const {
|
||||
isPending,
|
||||
isError,
|
||||
data: tickets,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['tickets', 'dashboard'],
|
||||
queryFn: (): Promise<Array<Ticket>> => {
|
||||
if (!pb.authStore.model) return Promise.reject('Not authenticated')
|
||||
return pb.collection('tickets').getFullList({
|
||||
sort: '-created',
|
||||
filter: pb.filter(`open = true && owner = {:owner}`, { owner: pb.authStore.model.id }),
|
||||
expand: 'owner,type'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const age = (ticket: Ticket) =>
|
||||
intervalToDuration({ start: new Date(ticket.created), end: new Date() }).days
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Card>
|
||||
<div v-if="tickets && tickets.length === 0" class="p-2 text-center text-sm text-gray-500">
|
||||
No open tickets
|
||||
</div>
|
||||
<PanelListElement v-else v-for="ticket in tickets" :key="ticket.id" class="gap-2 pr-1">
|
||||
<span>{{ ticket.name }}</span>
|
||||
<Separator orientation="vertical" class="h-4" />
|
||||
<span class="text-sm text-muted-foreground">{{ ticket.expand.type.singular }}</span>
|
||||
<Separator orientation="vertical" class="h-4" />
|
||||
<span class="text-sm text-muted-foreground">Open since {{ age(ticket) }} days</span>
|
||||
<RouterLink
|
||||
:to="{
|
||||
name: 'tickets',
|
||||
params: { type: ticket.type, id: ticket.id }
|
||||
}"
|
||||
:class="cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'ml-auto h-8')"
|
||||
>
|
||||
<span class="flex flex-row items-center text-sm text-gray-500">
|
||||
Go to {{ ticket.name }}
|
||||
<ChevronRight class="ml-2 h-4 w-4" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</PanelListElement>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
49
ui/src/components/dashboard/TicketOverTime.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import TanView from '@/components/TanView.vue'
|
||||
import { LineChart } from '@/components/ui/chart-line'
|
||||
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { getWeek } from 'date-fns'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Task } from '@/lib/types'
|
||||
|
||||
const {
|
||||
isPending,
|
||||
isError,
|
||||
data: tickets,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['tickets'],
|
||||
queryFn: (): Promise<Array<Task>> =>
|
||||
pb.collection('tickets').getFullList({
|
||||
sort: '-created',
|
||||
expand: 'owner,type'
|
||||
})
|
||||
})
|
||||
|
||||
const ticketsPerWeek = computed(() => {
|
||||
if (!tickets.value) return []
|
||||
|
||||
const weeks = tickets.value.reduce(
|
||||
(acc, ticket) => {
|
||||
const week = getWeek(ticket.created)
|
||||
acc[week] = (acc[week] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<number, number>
|
||||
)
|
||||
|
||||
return Object.entries(weeks).map(([week, count]) => ({
|
||||
week: parseInt(week),
|
||||
count
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TanView :isError="isError" :isPending="isPending" :error="error" :value="tickets">
|
||||
<LineChart class="h-40" :data="ticketsPerWeek" index="week" :categories="['count']" />
|
||||
</TanView>
|
||||
</template>
|
||||
38
ui/src/components/dashboard/TicketTypes.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import TanView from '@/components/TanView.vue'
|
||||
import { DonutChart } from '@/components/ui/chart-donut'
|
||||
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
|
||||
const {
|
||||
isPending,
|
||||
isError,
|
||||
data: types,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['sidebar'],
|
||||
queryFn: (): Promise<Array<any>> => pb.collection('sidebar').getFullList()
|
||||
})
|
||||
|
||||
const namedTypes = computed(() => {
|
||||
if (!types.value) return []
|
||||
return types.value.map((type) => {
|
||||
return {
|
||||
plural: type.plural,
|
||||
name: type.plural, // fixes the donut chart, which always expects "name" as the index field
|
||||
count: type.count
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TanView :isError="isError" :isPending="isPending" :error="error" :value="namedTypes">
|
||||
<div v-if="namedTypes" class="flex flex-1 items-center">
|
||||
<DonutChart index="plural" type="donut" category="count" :data="namedTypes" />
|
||||
</div>
|
||||
</TanView>
|
||||
</template>
|
||||
71
ui/src/components/form/JSONSchemaFormFields.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { JSONSchema } from '@/lib/types'
|
||||
|
||||
const model = defineModel<Record<string, any>>()
|
||||
|
||||
const props = defineProps<{
|
||||
schema: JSONSchema
|
||||
}>()
|
||||
|
||||
const formdata = ref<Record<string, any>>({})
|
||||
|
||||
onMounted(() => {
|
||||
if (!model.value) return
|
||||
|
||||
for (const key in props.schema.properties) {
|
||||
formdata.value[key] = model.value[key]
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
formdata,
|
||||
(newVal) => {
|
||||
model.value = { ...newVal }
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="(property, key) in schema.properties" :key="key">
|
||||
<FormField
|
||||
v-if="property.type === 'string'"
|
||||
:name="key"
|
||||
v-slot="{ componentField }"
|
||||
v-model="formdata[key]"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel :for="key" class="text-right">
|
||||
{{ property.title }}
|
||||
</FormLabel>
|
||||
<Input :id="key" class="col-span-3" v-bind="componentField" />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField
|
||||
v-else-if="property.type === 'boolean'"
|
||||
:name="key"
|
||||
v-slot="{ value, handleChange }"
|
||||
type="checkbox"
|
||||
v-model="formdata[key]"
|
||||
>
|
||||
<FormItem class="flex flex-row items-start gap-x-3 space-y-0 py-4">
|
||||
<FormControl>
|
||||
<Checkbox :checked="value" @update:checked="handleChange" />
|
||||
</FormControl>
|
||||
<div class="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
{{ property.title }}
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</template>
|
||||
90
ui/src/components/input/DynamicInput.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import ShortCut from '@/components/ShortCut.vue'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
// import { Textarea } from '@/components/ui/textarea'
|
||||
// import { Input } from '@/components/ui/input'
|
||||
|
||||
const model = defineModel({
|
||||
type: String
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'input',
|
||||
validator: (value: string) => ['input', 'textarea'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const active = ref(false)
|
||||
const text = ref(model.value)
|
||||
const input = ref<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
|
||||
const activate = () => {
|
||||
active.value = true
|
||||
text.value = model.value
|
||||
setTimeout(() => {
|
||||
if (input.value) {
|
||||
input.value.focus()
|
||||
}
|
||||
resize()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
model.value = text.value
|
||||
active.value = false
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
if (input.value && props.type === 'textarea') {
|
||||
input.value.style.height = input.value.scrollHeight + 'px'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full items-center">
|
||||
<div
|
||||
v-if="!active"
|
||||
@click="activate"
|
||||
class="flex w-full cursor-pointer bg-transparent p-1 focus-visible:outline-none"
|
||||
>
|
||||
{{ model || placeholder }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex w-full flex-row items-center border bg-transparent focus-visible:outline-none"
|
||||
>
|
||||
<div v-if="type === 'input'" class="flex w-full items-center">
|
||||
<input
|
||||
ref="input"
|
||||
autofocus
|
||||
v-model="text"
|
||||
:placeholder="placeholder"
|
||||
@keydown.enter="save"
|
||||
@blur="save"
|
||||
class="w-full border-none bg-transparent p-1 focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="type === 'textarea'" class="w-full">
|
||||
<textarea
|
||||
ref="input"
|
||||
v-model="text"
|
||||
:placeholder="placeholder"
|
||||
@keydown.enter="save"
|
||||
@blur="save"
|
||||
class="w-full border-none bg-transparent p-1 focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<ShortCut class="mr-2 text-nowrap" keys="Press ↵ to save" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
45
ui/src/components/input/DynamicMDEditor.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import MDEditor from '@/components/input/MDEditor.vue'
|
||||
import MarkdownView from '@/components/input/MarkdownView.vue'
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
const edit = defineModel('edit', {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save'])
|
||||
|
||||
export interface Props {
|
||||
placeholder?: string
|
||||
hideCancel?: boolean
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
placeholder: '',
|
||||
hideCancel: false,
|
||||
autofocus: false
|
||||
})
|
||||
|
||||
const cancel = () => (edit.value = false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-1">
|
||||
<MDEditor
|
||||
v-if="edit"
|
||||
v-model="model"
|
||||
class="-mx-1 -mt-1"
|
||||
:autofocus="autofocus"
|
||||
:placeholder="placeholder"
|
||||
:hideCancel="hideCancel"
|
||||
@save="emit('save')"
|
||||
@cancel="cancel"
|
||||
/>
|
||||
|
||||
<MarkdownView v-else :markdown="model ? model : ''" />
|
||||
</div>
|
||||
</template>
|
||||
100
ui/src/components/input/MDEditor.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import ShortCut from '@/components/ShortCut.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { EditorInstance } from 'vue3-easymde'
|
||||
|
||||
const model = defineModel<string>()
|
||||
|
||||
const emit = defineEmits(['save', 'cancel'])
|
||||
|
||||
export interface Props {
|
||||
placeholder?: string
|
||||
hideCancel?: boolean
|
||||
autofocus?: boolean
|
||||
}
|
||||
|
||||
const focus = ref(false)
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '',
|
||||
hideCancel: false,
|
||||
autofocus: false
|
||||
})
|
||||
|
||||
// meanwhile, vue3-easymde also expose particular instance
|
||||
const editorInstance = ref<EditorInstance | null>(null)
|
||||
// you can call getMDEInstance method to get easymde instance
|
||||
// if (editorInstance.value) {
|
||||
// console.log(editorInstance.value.getMDEInstance())
|
||||
// }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- https://github.com/Ionaru/easy-markdown-editor -->
|
||||
<vue-easymde
|
||||
v-model="model"
|
||||
class="prose dark:prose-invert"
|
||||
ref="editorInstance"
|
||||
@keydown.meta.enter="emit('save')"
|
||||
@keydown.ctrl.enter="emit('save')"
|
||||
@click="focus = true"
|
||||
@blur="focus = false"
|
||||
:options="{
|
||||
autofocus: props.autofocus,
|
||||
placeholder: props.placeholder,
|
||||
minHeight: '40px',
|
||||
toolbar: false,
|
||||
status: false,
|
||||
spellChecker: false,
|
||||
shortcuts: {
|
||||
togglePreview: null,
|
||||
toggleSideBySide: null,
|
||||
toggleFullScreen: null
|
||||
}
|
||||
}"
|
||||
/>
|
||||
|
||||
<div class="mt-2 flex gap-2">
|
||||
<Button
|
||||
v-if="!hideCancel"
|
||||
variant="secondary"
|
||||
class="ml-auto"
|
||||
size="sm"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
:variant="focus ? 'default' : 'secondary'"
|
||||
size="sm"
|
||||
@click="emit('save')"
|
||||
class="transition-colors"
|
||||
>
|
||||
Save
|
||||
<ShortCut keys="⌘ ↵" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.EasyMDEContainer .CodeMirror {
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: hsl(var(--card-foreground));
|
||||
background-color: hsl(var(--card));
|
||||
}
|
||||
|
||||
.CodeMirror-cursor {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.CodeMirror-cursor {
|
||||
border-color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
ui/src/components/input/MarkdownView.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
nosyntax?: boolean
|
||||
markdown: string
|
||||
}>()
|
||||
|
||||
const html = computed(() => marked(props.markdown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ prose: !nosyntax, 'dark:prose-invert': !nosyntax }" v-html="html" />
|
||||
</template>
|
||||
62
ui/src/components/layout/SideBar.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import IncidentNav from '@/components/sidebar/IncidentNav.vue'
|
||||
import NavList from '@/components/sidebar/NavList.vue'
|
||||
import UserDropDown from '@/components/sidebar/UserDropDown.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
import { Menu } from 'lucide-vue-next'
|
||||
|
||||
import { useCatalystStore } from '@/store/catalyst'
|
||||
|
||||
const catalystStore = useCatalystStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-[57px] items-center border-b bg-background">
|
||||
<img
|
||||
src="@/assets/flask.svg"
|
||||
alt="Catalyst"
|
||||
class="h-8 w-8 dark:hidden"
|
||||
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }"
|
||||
/>
|
||||
<img
|
||||
src="@/assets/flask_white.svg"
|
||||
alt="Catalyst"
|
||||
class="hidden h-8 w-8 dark:flex"
|
||||
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }"
|
||||
/>
|
||||
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
|
||||
</div>
|
||||
<NavList
|
||||
class="mt-auto"
|
||||
:is-collapsed="catalystStore.sidebarCollapsed"
|
||||
:links="[
|
||||
{
|
||||
title: 'Dashboard',
|
||||
icon: 'PanelsTopLeft',
|
||||
variant: 'ghost',
|
||||
to: '/dashboard'
|
||||
}
|
||||
]"
|
||||
/>
|
||||
<Separator />
|
||||
<IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" />
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<Separator />
|
||||
<UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" />
|
||||
<Separator />
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="catalystStore.toggleSidebar()"
|
||||
size="sm"
|
||||
class="m-2 justify-start px-3.5"
|
||||
>
|
||||
<Menu class="size-4" />
|
||||
<span v-if="!catalystStore.sidebarCollapsed" class="ml-2">Toggle Sidebar</span>
|
||||
</Button>
|
||||
</template>
|
||||
32
ui/src/components/layout/ThreeColumn.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import SideBar from '@/components/layout/SideBar.vue'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCatalystStore } from '@/store/catalyst'
|
||||
|
||||
const catalystStore = useCatalystStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<div class="flex h-full flex-row items-stretch bg-muted/40">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-48 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
|
||||
catalystStore.sidebarCollapsed && 'min-w-[50px]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SideBar />
|
||||
</div>
|
||||
<div class="w-72 flex-initial border-r">
|
||||
<slot name="list" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<slot name="single" />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
27
ui/src/components/layout/TwoColumn.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts" setup>
|
||||
import SideBar from '@/components/layout/SideBar.vue'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCatalystStore } from '@/store/catalyst'
|
||||
|
||||
const catalystStore = useCatalystStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="0">
|
||||
<div class="flex h-full flex-row items-stretch bg-muted/40">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-48 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
|
||||
catalystStore.sidebarCollapsed && 'min-w-[50px]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SideBar />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
@@ -1,273 +0,0 @@
|
||||
<template>
|
||||
<v-card data-app outlined>
|
||||
<v-card-title class="d-flex">
|
||||
<span>Edit Task</span>
|
||||
<v-spacer/>
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<template #activator="{ on }">
|
||||
<v-btn outlined v-on="on" class="mr-2" small>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-title class="headline">Delete Task</v-card-title>
|
||||
<v-card-text>Are you sure you want to delete this task?</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-btn color="blue darken-1" text @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="blue darken-1" text @click="deleteTask">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<v-btn @click="close" outlined small>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="task.name"
|
||||
label="Name"
|
||||
variant="underlined"/>
|
||||
<v-textarea
|
||||
v-model="task.description"
|
||||
label="Description"
|
||||
auto-grow
|
||||
rows="1"
|
||||
variant="underlined"/>
|
||||
<v-select
|
||||
v-model="task.type"
|
||||
:items="['input','automation','task']"
|
||||
label="Type"
|
||||
variant="underlined"/>
|
||||
|
||||
<AdvancedJSONSchemaEditor
|
||||
v-if="task.type === 'input'"
|
||||
:schema="task.schema"
|
||||
@save="task.schema = JSON.parse($event)" />
|
||||
|
||||
<v-select
|
||||
v-if="task.type === 'automation'"
|
||||
v-model="task.automation"
|
||||
:items="automations"
|
||||
item-text="id"
|
||||
item-value="id"
|
||||
label="Automation"
|
||||
variant="underlined"/>
|
||||
|
||||
<v-list v-if="task.type === 'automation'">
|
||||
<v-subheader class="pa-0" style="padding-inline-start: 0 !important;">Payload Mapping</v-subheader>
|
||||
<v-toolbar v-for="(expr, key) in task.payload" :key="key" class="next-row" flat dense>
|
||||
{{ key }}:
|
||||
<v-text-field
|
||||
v-model="task.payload[key]"
|
||||
label="Expression"
|
||||
variant="solo"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
<v-btn @click="deletePayloadMapping(key)" color="error" class="pa-0 ma-0" icon>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-toolbar class="next-row" flat dense>
|
||||
<v-text-field
|
||||
v-model="newPayloadMapping"
|
||||
label="Payload Field"
|
||||
variant="solo"
|
||||
bg-color="surface"
|
||||
hide-details
|
||||
density="compact"
|
||||
/>:
|
||||
<v-text-field
|
||||
v-model="newExpression"
|
||||
label="CAQL Expression"
|
||||
variant="solo"
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
<v-btn
|
||||
@click="addPayloadMapping"
|
||||
:disabled="!newPayloadMapping || !newExpression"
|
||||
class="pa-0 ma-0"
|
||||
icon>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
</v-list>
|
||||
|
||||
<v-list v-if="task.next || possibleNexts.length > 1">
|
||||
<v-subheader class="pa-0" style="padding-inline-start: 0 !important;">Next Task(s)</v-subheader>
|
||||
<v-toolbar v-for="(expr, key) in task.next" :key="key" class="next-row" flat dense>
|
||||
If
|
||||
<v-text-field
|
||||
v-model="task.next[key]"
|
||||
label="Condition (leave empty to always run)"
|
||||
variant="solo"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
run
|
||||
<span class="font-weight-black">
|
||||
{{ playbook.tasks[key].name }}
|
||||
</span>
|
||||
<v-btn @click="deleteNext(key)" color="error" class="pa-0 ma-0" icon>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-toolbar v-if="possibleNexts.length > 0" class="next-row" flat dense>
|
||||
If
|
||||
<v-text-field
|
||||
v-model="newCondition"
|
||||
label="Condition (leave empty to always run)"
|
||||
variant="solo"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
run
|
||||
<v-select
|
||||
v-model="newNext"
|
||||
item-text="name"
|
||||
item-value="key"
|
||||
:items="possibleNexts"
|
||||
variant="solo"
|
||||
bg-color="surface"
|
||||
hide-details
|
||||
density="compact"
|
||||
/>
|
||||
<v-btn
|
||||
@click="addNext"
|
||||
:disabled="!newNext"
|
||||
class="pa-0 ma-0"
|
||||
icon>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
</v-list>
|
||||
<v-switch
|
||||
v-if="parents.length > 1"
|
||||
label="Join (Require all previous tasks to be completed)"
|
||||
v-model="task.join"
|
||||
color="primary"/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {defineProps, ref, watch, defineEmits, del, set, onMounted, computed} from "vue";
|
||||
import AdvancedJSONSchemaEditor from "@/components/AdvancedJSONSchemaEditor.vue";
|
||||
import {API} from "@/services/api";
|
||||
import {AutomationResponse} from "@/client";
|
||||
|
||||
interface Task {
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
next: Record<string, string>;
|
||||
payload: Record<string, string>;
|
||||
join: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value: Task;
|
||||
possibleNexts: Array<Record<string, string>>;
|
||||
parents: Array<string>;
|
||||
playbook: object;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["input", "delete", "close"]);
|
||||
|
||||
const deleteDialog = ref(false);
|
||||
const deleteTask = () => emit("delete");
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const task = ref(props.value);
|
||||
watch(() => props.value, (value) => {
|
||||
task.value = value;
|
||||
});
|
||||
watch(task, (value) => {
|
||||
emit("input", value);
|
||||
});
|
||||
|
||||
// const task = computed({
|
||||
// get: () => {
|
||||
// console.log("get", props.value);
|
||||
// return props.value;
|
||||
// },
|
||||
// set: (value) => {
|
||||
// console.log("set", value);
|
||||
// emit("input", value);
|
||||
// }
|
||||
// });
|
||||
|
||||
const deleteNext = (key: string) => {
|
||||
del(task.value.next, key);
|
||||
};
|
||||
|
||||
const deletePayloadMapping = (key: string) => {
|
||||
del(task.value.payload, key);
|
||||
};
|
||||
|
||||
const newNext = ref('');
|
||||
const newCondition = ref('');
|
||||
|
||||
const newPayloadMapping = ref('');
|
||||
const newExpression = ref('');
|
||||
|
||||
watch(() => props.possibleNexts, () => {
|
||||
if (props.possibleNexts.length > 0) {
|
||||
newNext.value = props.possibleNexts[0].key;
|
||||
}
|
||||
}, {deep: true, immediate: true});
|
||||
|
||||
|
||||
const addNext = () => {
|
||||
if (task.value.next === undefined) {
|
||||
// task.value.next = {};
|
||||
set(task.value, 'next', {});
|
||||
}
|
||||
// task.value.next[newNext.value] = newCondition.value;
|
||||
set(task.value.next, newNext.value, newCondition.value);
|
||||
newNext.value = "";
|
||||
newCondition.value = "";
|
||||
};
|
||||
|
||||
const addPayloadMapping = () => {
|
||||
if (task.value.payload === undefined) {
|
||||
// task.value.payload = {};
|
||||
set(task.value, 'payload', {});
|
||||
}
|
||||
// task.value.payload[newPayloadMapping.value] = newExpression.value;
|
||||
set(task.value.payload, newPayloadMapping.value, newExpression.value);
|
||||
newPayloadMapping.value = "";
|
||||
newExpression.value = "";
|
||||
};
|
||||
|
||||
const automations = ref<Array<AutomationResponse>>([]);
|
||||
onMounted(() => {
|
||||
API.listAutomations().then((response) => {
|
||||
automations.value = response.data;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.next-row {
|
||||
padding-top: 10px;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.next-row .v-toolbar__content {
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<v-card outlined>
|
||||
<v-card-title>
|
||||
Create a new step
|
||||
<v-spacer />
|
||||
<v-btn @click="close" outlined small>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form v-model="valid">
|
||||
<v-text-field
|
||||
v-model="newTask.name"
|
||||
label="Name"
|
||||
:rules="[val => (val || '').length > 0 || 'This field is required']"
|
||||
variant="underlined"/>
|
||||
<v-textarea
|
||||
v-model="newTask.description"
|
||||
label="Description"
|
||||
auto-grow
|
||||
rows="1"
|
||||
variant="underlined"/>
|
||||
<v-text-field
|
||||
v-model="newTask.key"
|
||||
label="Key (generated automatically)"
|
||||
readonly
|
||||
disabled
|
||||
:rules="[val => (val || '').length > 0 || 'This field is required']"
|
||||
variant="underlined"/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-btn color="primary" @click="createTask" :disabled="!valid">Create</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {defineProps, ref, watch, defineEmits} from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
playbook: any;
|
||||
}>();
|
||||
|
||||
const valid = ref(false);
|
||||
const newTask = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
key: '',
|
||||
next: {},
|
||||
});
|
||||
|
||||
watch(newTask, (val) => {
|
||||
if (val.name) {
|
||||
const newKeyBase = val.name.toLowerCase().replace(/ /g, '_');
|
||||
let newKey = newKeyBase;
|
||||
if (!(newKey in props.playbook.tasks)) {
|
||||
newTask.value.key = newKey;
|
||||
} else {
|
||||
let i = 1;
|
||||
while (newKey in props.playbook.tasks) {
|
||||
newKey = newKeyBase + '_' + i;
|
||||
i++;
|
||||
}
|
||||
newTask.value.key = newKey;
|
||||
}
|
||||
}
|
||||
}, {deep: true});
|
||||
|
||||
const emit = defineEmits(["createTask", "close"]);
|
||||
|
||||
const createTask = () => {
|
||||
emit('createTask', newTask.value);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
};
|
||||
</script>
|
||||
@@ -1,164 +0,0 @@
|
||||
<template>
|
||||
<div id="graphwrapper">
|
||||
<div id="gab">
|
||||
<slot name="actionbar"/>
|
||||
</div>
|
||||
<v-toolbar
|
||||
id="gtb"
|
||||
class="ma-2"
|
||||
floating
|
||||
dense
|
||||
>
|
||||
<v-btn @click.prevent.stop="reset" title="Reset" icon>
|
||||
<v-icon color="#000">mdi-image-filter-center-focus</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click.prevent.stop="zoomIn" title="Zoom in" :disabled="isMaxZoom" icon>
|
||||
<v-icon color="#000">mdi-magnify-plus-outline</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click.prevent.stop="zoomOut" title="Zoom out" :disabled="isMinZoom" icon>
|
||||
<v-icon color="#000">mdi-magnify-minus-outline</v-icon>
|
||||
</v-btn>
|
||||
<slot name="toolbar"/>
|
||||
</v-toolbar>
|
||||
<div id="panzoom">
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref, defineProps, defineExpose} from "vue";
|
||||
import createPanZoom from "panzoom";
|
||||
import * as panzoom from "panzoom";
|
||||
|
||||
const props = defineProps<{
|
||||
config: panzoom.PanZoomOptions
|
||||
}>();
|
||||
|
||||
const panZoom = ref<panzoom.PanZoom | null>(null);
|
||||
|
||||
const zoomLevel = ref<number>(1);
|
||||
|
||||
const minZoom = ref<number>(0.5);
|
||||
const maxZoom = ref<number>(1.5);
|
||||
|
||||
const isMaxZoom = computed(() => {
|
||||
if (zoomLevel.value) {
|
||||
return zoomLevel.value >= maxZoom.value;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isMinZoom = computed(() => {
|
||||
if (zoomLevel.value) {
|
||||
return zoomLevel.value <= minZoom.value;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const initialZoom = ref<number>(1);
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = document.getElementById("panzoom")
|
||||
if (!canvas) {
|
||||
throw new Error("No element with id panzoom")
|
||||
}
|
||||
|
||||
const firstChild = canvas.firstElementChild
|
||||
if (!firstChild) {
|
||||
throw new Error("No child element")
|
||||
}
|
||||
|
||||
const startX = canvas.getBoundingClientRect().width / 2 - firstChild.getBoundingClientRect().width / 2
|
||||
const startY = canvas.getBoundingClientRect().height / 2 - firstChild.getBoundingClientRect().height / 2
|
||||
|
||||
initialZoom.value = props.config.initialZoom ? props.config.initialZoom : 1
|
||||
minZoom.value = props.config.initialZoom / 2
|
||||
maxZoom.value = props.config.initialZoom * 2
|
||||
|
||||
panZoom.value = createPanZoom(canvas, {
|
||||
...props.config,
|
||||
zoomDoubleClickSpeed: 1, // disable double click zoom
|
||||
autocenter: true,
|
||||
initialX: startX,
|
||||
initialY: startY,
|
||||
minZoom: minZoom.value,
|
||||
maxZoom: maxZoom.value,
|
||||
});
|
||||
|
||||
panZoom.value.on("zoom", (e: panzoom.PanZoom) => {
|
||||
zoomLevel.value = e.getTransform().scale;
|
||||
});
|
||||
|
||||
reset(false);
|
||||
});
|
||||
|
||||
const ZOOM_FACTOR = 0.255
|
||||
|
||||
const zoomIn = () => {
|
||||
if (!panZoom.value) return
|
||||
const currentZoom = panZoom.value.getTransform().scale
|
||||
panZoom.value.smoothZoomAbs(0, 0, currentZoom + ZOOM_FACTOR)
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
if (!panZoom.value) return
|
||||
const currentZoom = panZoom.value.getTransform().scale
|
||||
panZoom.value.smoothZoomAbs(0, 0, currentZoom - ZOOM_FACTOR)
|
||||
}
|
||||
|
||||
const reset = (smooth = true) => {
|
||||
if (!panZoom.value) return
|
||||
|
||||
const canvas = document.getElementById("panzoom")
|
||||
if (!canvas) {
|
||||
throw new Error("No element with id panzoom")
|
||||
}
|
||||
|
||||
const firstChild = canvas.firstElementChild
|
||||
if (!firstChild) {
|
||||
throw new Error("No child element")
|
||||
}
|
||||
|
||||
const startX = canvas.getBoundingClientRect().width / 2 - firstChild.getBoundingClientRect().width / 2
|
||||
const startY = canvas.getBoundingClientRect().height / 2 - firstChild.getBoundingClientRect().height / 2
|
||||
|
||||
panZoom.value.pause()
|
||||
if (smooth) {
|
||||
panZoom.value.smoothZoomAbs(0, 0, initialZoom.value)
|
||||
panZoom.value.smoothMoveTo(startX * 0.5, startY)
|
||||
} else {
|
||||
panZoom.value.zoomTo(0, 0, initialZoom.value)
|
||||
panZoom.value.moveTo(startX * 0.5, startY)
|
||||
}
|
||||
panZoom.value.resume()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reset,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomLevel,
|
||||
isMaxZoom,
|
||||
isMinZoom,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#graphwrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#gtb .v-toolbar__content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#panzoom {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
cursor: move;
|
||||
}
|
||||
</style>
|
||||
@@ -1,235 +0,0 @@
|
||||
<template>
|
||||
<v-row style="position: relative; min-height: 600px">
|
||||
<v-col :cols="(selectedStep && selectedStep in playbook.tasks) || showNewDialog ? 8 :12">
|
||||
<v-card style="overflow: hidden" outlined>
|
||||
<v-card-text>
|
||||
<PanZoom ref="panZoomPanel" :config="panZoomConfig">
|
||||
<template #default>
|
||||
<PlaybookGraph
|
||||
v-if="playbook"
|
||||
:playbook="playbook"
|
||||
:horizontal="horizontal"
|
||||
:selected="selectedStep"
|
||||
@update:selected="showNewDialog = false; selectedStep = $event"
|
||||
/>
|
||||
</template>
|
||||
<template #actionbar>
|
||||
<v-btn @click="showNewDialog = true; selectedStep = ''" large rounded>
|
||||
<v-icon color="#000">mdi-plus</v-icon>
|
||||
New Step
|
||||
</v-btn>
|
||||
</template>
|
||||
<template #toolbar>
|
||||
<v-btn @click="toggleOrientation" label="Toggle Orientation" icon>
|
||||
<v-icon color="#000">mdi-format-rotate-90</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</PanZoom>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="(selectedStep && selectedStep in playbook.tasks) || showNewDialog"
|
||||
cols="4"
|
||||
>
|
||||
<EditTask
|
||||
v-if="selectedStep && selectedStep in playbook.tasks"
|
||||
:value="playbook.tasks[selectedStep]"
|
||||
@input="updateTask"
|
||||
:possibleNexts="possibleNexts"
|
||||
:parents="parents"
|
||||
:playbook="playbook"
|
||||
@delete="deleteTask"
|
||||
@close="unselectStep"/>
|
||||
<NewTask
|
||||
v-else-if="showNewDialog"
|
||||
:playbook="playbook"
|
||||
@createTask="createTask"
|
||||
@close="showNewDialog = false"/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, nextTick, ref, defineProps, set, defineEmits, del} from "vue";
|
||||
import PlaybookGraph from "@/components/playbookeditor/PlaybookGraph.vue";
|
||||
import PanZoom from "@/components/playbookeditor/PanZoom.vue";
|
||||
import EditTask from "@/components/playbookeditor/EditTask.vue";
|
||||
import NewTask from "@/components/playbookeditor/NewTask.vue";
|
||||
|
||||
const props = defineProps({
|
||||
'value': {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['input']);
|
||||
|
||||
const playbook = computed({
|
||||
get: () => props.value,
|
||||
set: (value) => {
|
||||
emit('input', value);
|
||||
}
|
||||
});
|
||||
|
||||
// selected step
|
||||
const selectedStep = ref("")
|
||||
const unselectStep = () => {
|
||||
selectedStep.value = "";
|
||||
}
|
||||
|
||||
const updateTask = (task: any) => {
|
||||
set(playbook.value.tasks, selectedStep.value, task);
|
||||
emit('input', playbook.value);
|
||||
}
|
||||
|
||||
const deleteTask = () => {
|
||||
const parents = Array<any>();
|
||||
for (const task in playbook.value.tasks) {
|
||||
if (playbook.value.tasks[task].next && playbook.value.tasks[task].next[selectedStep.value]) {
|
||||
parents.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
const children = Array<any>();
|
||||
if (playbook.value.tasks[selectedStep.value].next) {
|
||||
for (const next in playbook.value.tasks[selectedStep.value].next) {
|
||||
children.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parent of parents) {
|
||||
del(playbook.value.tasks[parent].next, selectedStep.value);
|
||||
for (const child of children) {
|
||||
set(playbook.value.tasks[parent].next, child, playbook.value.tasks[selectedStep.value].next[child]);
|
||||
}
|
||||
}
|
||||
|
||||
del(playbook.value.tasks, selectedStep.value);
|
||||
|
||||
// for (const task in playbook.value.tasks) {
|
||||
// if (playbook.value.tasks[task].next && playbook.value.tasks[task].next[selectedStep.value]) {
|
||||
// del(playbook.value.tasks[task].next, selectedStep.value);
|
||||
// }
|
||||
// }
|
||||
// del(playbook.value.tasks, selectedStep.value);
|
||||
emit('input', playbook.value);
|
||||
}
|
||||
|
||||
const panZoomConfig = ref({})
|
||||
|
||||
const panZoomPanel = ref(null);
|
||||
|
||||
const horizontal = ref(false);
|
||||
const toggleOrientation = () => {
|
||||
horizontal.value = !horizontal.value;
|
||||
|
||||
nextTick(() => {
|
||||
panZoomPanel.value?.reset(false);
|
||||
});
|
||||
}
|
||||
|
||||
const showNewDialog = ref(false);
|
||||
const createTask = (task: any) => {
|
||||
const t = {
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
type: 'task',
|
||||
next: {}
|
||||
};
|
||||
set(playbook.value.tasks, task.key, t);
|
||||
selectedStep.value = task.key;
|
||||
};
|
||||
|
||||
// edit task
|
||||
const possibleNexts = computed(() => {
|
||||
if (!selectedStep.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let nexts = Object.keys(playbook.value.tasks);
|
||||
nexts = nexts.filter((n) => n !== selectedStep.value);
|
||||
|
||||
// remove any nexts that are already in the list
|
||||
if (playbook.value.tasks[selectedStep.value] && 'next' in playbook.value.tasks[selectedStep.value]) {
|
||||
for (const next in playbook.value.tasks[selectedStep.value].next) {
|
||||
nexts = nexts.filter((n) => n !== next);
|
||||
}
|
||||
}
|
||||
|
||||
// remove parents recursively
|
||||
const parents = findAncestor(selectedStep.value);
|
||||
for (const parent of parents) {
|
||||
nexts = nexts.filter((n) => n !== parent);
|
||||
}
|
||||
|
||||
const result: Array<Record<string, string>> = [];
|
||||
for (const next of nexts) {
|
||||
if (next && playbook.value.tasks[next].name) {
|
||||
result.push({"key": next, "name": playbook.value.tasks[next].name});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const findAncestor = (step: string): Array<string> => {
|
||||
const parents: Array<string> = [];
|
||||
for (const task in playbook.value.tasks) {
|
||||
for (const next in playbook.value.tasks[task].next) {
|
||||
if (next === step) {
|
||||
if (!parents.includes(task)) {
|
||||
parents.push(task);
|
||||
parents.push(...findAncestor(task));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
};
|
||||
|
||||
const parents = computed(() => {
|
||||
const parents: Array<string> = [];
|
||||
for (const task in playbook.value.tasks) {
|
||||
for (const next in playbook.value.tasks[task].next) {
|
||||
if (next === selectedStep.value) {
|
||||
if (!parents.includes(task)) {
|
||||
parents.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#graphwrapper {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#gab {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 20px;
|
||||
border-radius: 30px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
#gtb {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 20px;
|
||||
border-radius: 30px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
#gtb .v-toolbar__content > .v-btn:first-child {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
#gtb .v-toolbar__content > .v-btn:last-child {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,511 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
class="pe-graph"
|
||||
:width="width * scale"
|
||||
:height="height * scale"
|
||||
:viewBox="`${minX - config.graphPadding} ${minY - config.graphPadding} ${maxX - minX + config.boxWidth + config.graphPadding * 2} ${maxY - minY + config.boxHeight + config.graphPadding * 2}`"
|
||||
>
|
||||
<defs>
|
||||
<path
|
||||
id="cog"
|
||||
d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
|
||||
<path
|
||||
id="clipboard-outline"
|
||||
d="M19,3H14.82C14.4,1.84 13.3,1 12,1C10.7,1 9.6,1.84 9.18,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M12,3A1,1 0 0,1 13,4A1,1 0 0,1 12,5A1,1 0 0,1 11,4A1,1 0 0,1 12,3M7,7H17V5H19V19H5V5H7V7Z"/>
|
||||
<path
|
||||
id="keyboard"
|
||||
d="M19,10H17V8H19M19,13H17V11H19M16,10H14V8H16M16,13H14V11H16M16,17H8V15H16M7,10H5V8H7M7,13H5V11H7M8,11H10V13H8M8,8H10V10H8M11,11H13V13H11M11,8H13V10H11M20,5H4C2.89,5 2,5.89 2,7V17A2,2 0 0,0 4,19H20A2,2 0 0,0 22,17V7C22,5.89 21.1,5 20,5Z"/>
|
||||
<path
|
||||
id="star"
|
||||
d="M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z"/>
|
||||
</defs>
|
||||
<g class="pe-links">
|
||||
<path v-for="(link, index) in links" :key="index" :d="link.path"/>
|
||||
</g>
|
||||
<g class="pe-activelinks">
|
||||
<path
|
||||
v-for="(link, index) in links"
|
||||
:key="index"
|
||||
:d="link.path"
|
||||
:class="{
|
||||
'hovered': !selectedNode && (link.source === hoverNode || link.target === hoverNode),
|
||||
'selected': link.source === selectedNode || link.target === selectedNode
|
||||
}"
|
||||
/>
|
||||
</g>
|
||||
<g class="pe-nodes">
|
||||
<g
|
||||
v-for="(node, index) in positionedNodes"
|
||||
:key="index">
|
||||
<g
|
||||
v-if="node.type === 'start'"
|
||||
:transform="`translate(${props.horizontal ? node.x + config.boxWidth : node.x + (config.boxWidth / 2)}, ${node.y})`"
|
||||
class="start"
|
||||
>
|
||||
<circle
|
||||
:transform="`translate(0, ${config.boxHeight / 2})`"
|
||||
r="20"
|
||||
/>
|
||||
<g
|
||||
class="icon"
|
||||
:transform="`translate(${0 - 12}, ${(config.boxHeight - 24) / 2})`"
|
||||
>
|
||||
<use href="#star" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
v-else
|
||||
:transform="`translate(${node.x}, ${node.y})`"
|
||||
:class="{
|
||||
'pe-node': true,
|
||||
'start': node.type === 'start',
|
||||
'hovered': node.id === hoverNode,
|
||||
'selected': node.id === selectedNode,
|
||||
'unhovered': hoverNode && !selectedNode && node.id !== hoverNode,
|
||||
'unselected': selectedNode && node.id !== selectedNode,
|
||||
}"
|
||||
@mouseover="hoverNode = node.id"
|
||||
@mouseout="hoverNode = null"
|
||||
@click="selectedNode === node.id ? selectedNode = null : selectedNode = node.id"
|
||||
>
|
||||
<rect
|
||||
class="pe-box"
|
||||
:width="config.boxWidth"
|
||||
:height="config.boxHeight"
|
||||
:rx="config.boxRadius"
|
||||
:ry="config.boxRadius"
|
||||
/>
|
||||
<g
|
||||
class="pe-icon"
|
||||
:transform="`translate(10, ${(config.boxHeight - 24) / 2})`"
|
||||
>
|
||||
<use v-if="node.type === 'automation'" href="#cog"/>
|
||||
<use v-else-if="node.type === 'start'" href="#star"/>
|
||||
<use v-else-if="node.type === 'input'" href="#keyboard"/>
|
||||
<use v-else href="#clipboard-outline"/>
|
||||
</g>
|
||||
<text
|
||||
class="pe-text"
|
||||
:x="config.boxWidth / 2"
|
||||
:y="config.boxHeight / 2"
|
||||
>
|
||||
{{ node.label ? node.label : node.id }}
|
||||
</text>
|
||||
<g
|
||||
class="add"
|
||||
:transform="`translate(${config.boxWidth - 34}, ${(config.boxHeight - 24) / 2})`"
|
||||
>
|
||||
<use href="#star" fill="none"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g class="pe-connectors">
|
||||
<g
|
||||
v-for="(link, index) in links"
|
||||
:key="index"
|
||||
:class="{
|
||||
'pe-connector': true,
|
||||
'hovered': !selectedNode && (link.source === hoverNode || link.target === hoverNode),
|
||||
'selected': link.source === selectedNode || link.target === selectedNode
|
||||
}"
|
||||
>
|
||||
<circle
|
||||
v-if="link.source !== 'start'"
|
||||
:cx="link.start.x"
|
||||
:cy="link.start.y"
|
||||
:r="4"/>
|
||||
<circle
|
||||
:cx="link.end.x"
|
||||
:cy="link.end.y"
|
||||
:r="7"/>
|
||||
<circle
|
||||
:cx="link.end.x"
|
||||
:cy="link.end.y"
|
||||
:r="5"
|
||||
fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as d3 from "d3";
|
||||
import {digl} from "@crinkles/digl";
|
||||
import {Edge as DiglEdge, Node as DiglNode, Position, Rank} from "@crinkles/digl/dist/types";
|
||||
import {computed, defineEmits, ref, Ref, defineProps, ComputedRef} from "vue";
|
||||
|
||||
interface Config {
|
||||
graphPadding: number;
|
||||
boxWidth: number;
|
||||
boxHeight: number;
|
||||
boxMarginX: number;
|
||||
boxMarginY: number;
|
||||
boxRadius: number;
|
||||
lineDistance: number;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
playbook: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
selected: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
horizontal: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 1
|
||||
}
|
||||
})
|
||||
|
||||
const config = ref({
|
||||
graphPadding: 10,
|
||||
boxWidth: 220,
|
||||
boxHeight: 40,
|
||||
boxMarginX: 60,
|
||||
boxMarginY: 70,
|
||||
boxRadius: 20,
|
||||
lineDistance: 0,
|
||||
});
|
||||
|
||||
interface Edge extends DiglEdge {
|
||||
label?: { text: string, x: number, y: number };
|
||||
}
|
||||
|
||||
interface Node extends DiglNode {
|
||||
type: 'automation' | 'input' | 'task' | 'start';
|
||||
}
|
||||
|
||||
const edges = computed(() => {
|
||||
const edges: Array<Edge> = [];
|
||||
|
||||
for (const key in props.playbook.tasks) {
|
||||
for (const next in props.playbook.tasks[key].next) {
|
||||
edges.push({
|
||||
source: key,
|
||||
target: next,
|
||||
label: props.playbook.tasks[key].next[next]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rootNodes = nodes.value.filter(node => edges.every(edge => edge.target !== node.id));
|
||||
for (const node of rootNodes) {
|
||||
if (node.id !== 'start') {
|
||||
edges.push({
|
||||
source: 'start',
|
||||
target: node.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
});
|
||||
|
||||
const nodes: ComputedRef<Array<Node>> = computed(() => {
|
||||
const nodes = [{
|
||||
id: "start",
|
||||
label: "Start",
|
||||
type: "start" as 'automation' | 'input' | 'task' | 'start',
|
||||
}];
|
||||
for (const key in props.playbook.tasks) {
|
||||
nodes.push({
|
||||
id: key,
|
||||
label: props.playbook.tasks[key].name,
|
||||
type: props.playbook.tasks[key].type
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
|
||||
|
||||
const hoverNode: Ref<string | null> = ref(null);
|
||||
|
||||
const emits = defineEmits(['update:selected']);
|
||||
const selectedNode = computed({
|
||||
get: () => props.selected,
|
||||
set: (newVal) => {
|
||||
emits('update:selected', newVal);
|
||||
}
|
||||
});
|
||||
|
||||
const rankses: Ref<Array<Array<Rank>>> = computed(() => {
|
||||
return digl(edges.value);
|
||||
});
|
||||
|
||||
const ranks = computed(() => {
|
||||
return rankses.value.length > 0 ? rankses.value[0] : [];
|
||||
});
|
||||
|
||||
const positionedNodes = computed(() => {
|
||||
return positioning(config.value, props.horizontal, nodes.value, ranks.value);
|
||||
});
|
||||
|
||||
const width = computed(() => {
|
||||
return maxX.value - minX.value + config.value.graphPadding * 2 + config.value.boxWidth;
|
||||
});
|
||||
|
||||
const height = computed(() => {
|
||||
return maxY.value - minY.value + config.value.graphPadding * 2 + config.value.boxHeight;
|
||||
});
|
||||
|
||||
const minX = computed(() => {
|
||||
if (props.horizontal) {
|
||||
return Math.min(...positionedNodes.value.map(node => node.x)) + config.value.boxWidth - 22;
|
||||
}
|
||||
return Math.min(...positionedNodes.value.map(node => node.x));
|
||||
});
|
||||
|
||||
const minY = computed(() => {
|
||||
if (!props.horizontal) {
|
||||
return Math.min(...positionedNodes.value.map(node => node.y)) + config.value.boxHeight - 30;
|
||||
}
|
||||
return Math.min(...positionedNodes.value.map(node => node.y));
|
||||
});
|
||||
|
||||
const maxX = computed(() => {
|
||||
return Math.max(...positionedNodes.value.map(node => node.x));
|
||||
});
|
||||
|
||||
const maxY = computed(() => {
|
||||
return Math.max(...positionedNodes.value.map(node => node.y)) + 50;
|
||||
});
|
||||
|
||||
const links = computed(() => {
|
||||
return edges.value.map(edge => {
|
||||
const source = positionedNodes.value.find(node => node.id === edge.source);
|
||||
const target = positionedNodes.value.find(node => node.id === edge.target);
|
||||
|
||||
if (!source || !target) return;
|
||||
|
||||
// index within rank
|
||||
const sourceIndex = ranks.value.find(rank => rank.includes(edge.source))?.indexOf(edge.source);
|
||||
if (sourceIndex === undefined) {
|
||||
throw new Error(`sourceIndex is undefined for ${edge.source}`);
|
||||
}
|
||||
|
||||
const path = props.horizontal ?
|
||||
horizontalConnectionLine(source, target, sourceIndex, config.value) :
|
||||
verticalConnectionLine(source, target, sourceIndex, config.value);
|
||||
|
||||
const start = props.horizontal ?
|
||||
{x: source.x + config.value.boxWidth, y: source.y + config.value.boxHeight / 2} :
|
||||
{x: source.x + config.value.boxWidth / 2, y: source.y + config.value.boxHeight};
|
||||
|
||||
const end = props.horizontal ?
|
||||
{x: target.x, y: target.y + config.value.boxHeight / 2} :
|
||||
{x: target.x + config.value.boxWidth / 2, y: target.y};
|
||||
|
||||
return {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
path: path.toString(),
|
||||
start: start,
|
||||
end: end,
|
||||
};
|
||||
}).filter(link => link);
|
||||
});
|
||||
|
||||
interface PositionedNode extends Node, Position {
|
||||
}
|
||||
|
||||
function positioning(
|
||||
config: Config,
|
||||
horizontal: boolean,
|
||||
nodes: Node[],
|
||||
ranks: Rank[]
|
||||
): PositionedNode[] {
|
||||
const _nodes: PositionedNode[] = [];
|
||||
const _h = horizontal;
|
||||
|
||||
ranks.forEach((rank, i) => {
|
||||
const xStart = _h
|
||||
? (config.boxWidth + config.boxMarginX) * i
|
||||
: -0.5 * (rank.length - 1) * (config.boxWidth + config.boxMarginX);
|
||||
const yStart = _h
|
||||
? -0.5 * (rank.length - 1) * (config.boxHeight + config.boxMarginY)
|
||||
: (config.boxHeight + config.boxMarginY) * i;
|
||||
|
||||
rank.forEach((nodeId, nIndex) => {
|
||||
const _node: Node = nodes.find((n) => n.id == nodeId) as Node;
|
||||
if (!_node) return;
|
||||
const x = _h ? xStart : xStart + (config.boxWidth + config.boxMarginX) * nIndex;
|
||||
const y = _h ? yStart + (config.boxHeight + config.boxMarginY) * nIndex : yStart;
|
||||
_nodes.push({..._node, x, y});
|
||||
});
|
||||
});
|
||||
|
||||
return _nodes;
|
||||
}
|
||||
|
||||
function verticalConnectionLine(source: PositionedNode, target: PositionedNode, sourceIndex: number, config: Config) {
|
||||
const sourceBottomCenter = {
|
||||
x: source.x + config.boxWidth / 2,
|
||||
y: source.y + config.boxHeight,
|
||||
};
|
||||
|
||||
const targetTopCenter = {
|
||||
x: target.x + config.boxWidth / 2,
|
||||
y: target.y,
|
||||
};
|
||||
|
||||
const path = d3.path();
|
||||
path.moveTo(sourceBottomCenter.x, sourceBottomCenter.y);
|
||||
|
||||
const lineCurve = config.boxMarginY / 2;
|
||||
|
||||
if (sourceBottomCenter.x == targetTopCenter.x) {
|
||||
path.lineTo(targetTopCenter.x, targetTopCenter.y);
|
||||
} else if (sourceBottomCenter.x < targetTopCenter.x) {
|
||||
if (target.y !== source.y + config.boxHeight + config.boxMarginY) {
|
||||
path.lineTo(sourceBottomCenter.x, target.y - config.boxMarginY);
|
||||
}
|
||||
sourceBottomCenter.y = target.y - config.boxMarginY;
|
||||
path.quadraticCurveTo(
|
||||
sourceBottomCenter.x, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
sourceBottomCenter.x + lineCurve, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
);
|
||||
path.lineTo(targetTopCenter.x - lineCurve, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance);
|
||||
path.quadraticCurveTo(
|
||||
targetTopCenter.x, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance,
|
||||
targetTopCenter.x, targetTopCenter.y,
|
||||
);
|
||||
} else {
|
||||
if (target.y !== source.y + config.boxHeight + config.boxMarginY) {
|
||||
path.lineTo(sourceBottomCenter.x, target.y - config.boxMarginY);
|
||||
}
|
||||
sourceBottomCenter.y = target.y - config.boxMarginY;
|
||||
path.quadraticCurveTo(
|
||||
sourceBottomCenter.x, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
sourceBottomCenter.x - lineCurve, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
);
|
||||
path.lineTo(targetTopCenter.x + lineCurve, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance);
|
||||
path.quadraticCurveTo(
|
||||
targetTopCenter.x, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance,
|
||||
targetTopCenter.x, targetTopCenter.y,
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function horizontalConnectionLine(source: PositionedNode, target: PositionedNode, sourceIndex: number, config: Config) {
|
||||
const sourceRightCenter = {
|
||||
x: source.x + config.boxWidth,
|
||||
y: source.y + config.boxHeight / 2,
|
||||
};
|
||||
|
||||
const targetLeftCenter = {
|
||||
x: target.x,
|
||||
y: target.y + config.boxHeight / 2,
|
||||
};
|
||||
|
||||
const path = d3.path();
|
||||
path.moveTo(sourceRightCenter.x, sourceRightCenter.y);
|
||||
|
||||
const lineCurve = config.boxMarginX / 2;
|
||||
|
||||
if (sourceRightCenter.y == targetLeftCenter.y) {
|
||||
path.lineTo(targetLeftCenter.x, targetLeftCenter.y);
|
||||
} else if (sourceRightCenter.y < targetLeftCenter.y) {
|
||||
if (target.x !== source.x + config.boxWidth + config.boxMarginX) {
|
||||
path.lineTo(target.x - config.boxMarginX, sourceRightCenter.y);
|
||||
}
|
||||
sourceRightCenter.x = target.x - config.boxMarginX;
|
||||
path.quadraticCurveTo(
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y,
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y + lineCurve,
|
||||
);
|
||||
path.lineTo(targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y - lineCurve);
|
||||
path.quadraticCurveTo(
|
||||
targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y,
|
||||
targetLeftCenter.x, targetLeftCenter.y,
|
||||
);
|
||||
} else {
|
||||
if (target.x !== source.x + config.boxWidth + config.boxMarginX) {
|
||||
path.lineTo(target.x - config.boxMarginX, sourceRightCenter.y);
|
||||
}
|
||||
sourceRightCenter.x = target.x - config.boxMarginX;
|
||||
path.quadraticCurveTo(
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y,
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y - lineCurve,
|
||||
);
|
||||
path.lineTo(targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y + lineCurve);
|
||||
path.quadraticCurveTo(
|
||||
targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y,
|
||||
targetLeftCenter.x, targetLeftCenter.y,
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
svg .pe-node.unhovered {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
svg .pe-node .pe-box {
|
||||
fill: white;
|
||||
stroke: #4C566A;
|
||||
stroke-width: 1px;
|
||||
filter: drop-shadow(0 1.5px 1.5px rgba(0, 0, 0, .15));
|
||||
}
|
||||
|
||||
svg .pe-node.selected .pe-box {
|
||||
stroke: #88C0D0;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
svg .pe-node text {
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
svg .pe-node:hover .pe-box {
|
||||
filter: drop-shadow(0 0 0.1rem #333);
|
||||
}
|
||||
|
||||
svg .pe-node:hover .pe-box,
|
||||
svg .pe-node:hover text {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg .pe-node:hover .add use {
|
||||
/* fill: red !important; */
|
||||
}
|
||||
|
||||
svg .pe-links path {
|
||||
stroke: black;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .15));
|
||||
}
|
||||
|
||||
svg .pe-activelinks path {
|
||||
stroke: none;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
svg .pe-activelinks path.selected,
|
||||
svg .pe-activelinks path.hovered {
|
||||
stroke-width: 3;
|
||||
stroke-linejoin: round;
|
||||
stroke: #88C0D0;
|
||||
filter: drop-shadow(0 0 0.1rem #88C0D0);
|
||||
}
|
||||
|
||||
</style>
|
||||
102
ui/src/components/sidebar/IncidentNav.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" setup>
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
import { LoaderCircle } from 'lucide-vue-next'
|
||||
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Type } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
defineProps<{
|
||||
isCollapsed: boolean
|
||||
}>()
|
||||
|
||||
const {
|
||||
isPending,
|
||||
isError,
|
||||
data: sidebar,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['sidebar'],
|
||||
queryFn: (): Promise<Array<any>> => pb.collection('sidebar').getFullList()
|
||||
})
|
||||
|
||||
const variant = (t: Type): 'default' | 'ghost' => (route.params.type === t.id ? 'default' : 'ghost')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:data-collapsed="isCollapsed"
|
||||
class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2"
|
||||
>
|
||||
<div v-if="isPending" class="flex h-screen w-screen items-center justify-center">
|
||||
<LoaderCircle class="h-16 w-16 animate-spin text-primary" />
|
||||
</div>
|
||||
<Alert v-else-if="isError" variant="destructive" class="mb-4 h-screen w-screen">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
<nav
|
||||
v-else-if="sidebar"
|
||||
class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"
|
||||
>
|
||||
<template v-for="(typ, index) of sidebar">
|
||||
<Tooltip v-if="isCollapsed" :key="`1-${index}`" :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<RouterLink
|
||||
:to="`/tickets/${typ.id}`"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: variant(typ), size: 'icon' }),
|
||||
'h-9 w-9',
|
||||
variant(typ) === 'default' &&
|
||||
'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Icon :name="typ.icon" class="size-4" />
|
||||
<span class="sr-only">{{ typ.plural }}</span>
|
||||
</RouterLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex items-center gap-4">
|
||||
{{ typ.plural }}
|
||||
<span v-if="typ.count" class="ml-auto text-muted-foreground">
|
||||
{{ typ.count }}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<RouterLink
|
||||
v-else
|
||||
:key="`2-${index}`"
|
||||
:to="`/tickets/${typ.id}`"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: variant(typ), size: 'sm' }),
|
||||
variant(typ) === 'default' &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Icon :name="typ.icon" class="mr-2 size-4" />
|
||||
{{ typ.plural }}
|
||||
<span
|
||||
v-if="typ.count"
|
||||
:class="cn('ml-auto', variant(typ) === 'default' && 'text-background dark:text-white')"
|
||||
>
|
||||
{{ typ.count }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
63
ui/src/components/sidebar/NavButton.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{
|
||||
isCollapsed: boolean
|
||||
index: number
|
||||
title: string
|
||||
label?: string
|
||||
icon: string
|
||||
variant: 'default' | 'ghost'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip v-if="isCollapsed" :key="`1-${index}`" :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: variant, size: 'icon' }),
|
||||
'h-9 w-9',
|
||||
variant === 'default' &&
|
||||
'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Icon :name="icon" class="size-4" />
|
||||
<span class="sr-only">{{ title }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex items-center gap-4">
|
||||
{{ title }}
|
||||
<span v-if="label" class="ml-auto text-muted-foreground">
|
||||
{{ label }}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
v-else
|
||||
:key="`2-${index}`"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: variant, size: 'sm' }),
|
||||
variant === 'default' &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Icon :name="icon" class="mr-2 size-4" />
|
||||
{{ title }}
|
||||
<span
|
||||
v-if="label"
|
||||
:class="cn('ml-auto', variant === 'default' && 'text-background dark:text-white')"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
86
ui/src/components/sidebar/NavItem.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts" setup>
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
export interface LinkProp {
|
||||
title: string
|
||||
label?: string
|
||||
icon: string
|
||||
to: string
|
||||
variant: 'default' | 'ghost'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
isCollapsed: boolean
|
||||
index: number
|
||||
link: LinkProp
|
||||
}>()
|
||||
|
||||
const variant = computed((): 'default' | 'ghost' =>
|
||||
route.path.startsWith(props.link.to) ? 'default' : 'ghost'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip v-if="isCollapsed" :key="`1-${index}`" :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<component
|
||||
:is="link.disabled ? 'span' : 'router-link'"
|
||||
:to="link.to"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: variant, size: 'icon' }),
|
||||
'h-9 w-9',
|
||||
link.variant === 'default' &&
|
||||
'dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white',
|
||||
link.disabled &&
|
||||
'text-muted-foreground hover:bg-transparent hover:text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Icon :name="link.icon" class="size-4" />
|
||||
<span class="sr-only">{{ link.title }}</span>
|
||||
</component>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex items-center gap-4">
|
||||
{{ link.title }}
|
||||
<span v-if="link.label" class="ml-auto text-muted-foreground">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<component
|
||||
:is="link.disabled ? 'span' : 'router-link'"
|
||||
v-else
|
||||
:key="`2-${index}`"
|
||||
:to="link.to"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({ variant: variant, size: 'sm' }),
|
||||
link.variant === 'default' &&
|
||||
'dark:bg-muted dark:text-white dark:hover:bg-muted dark:hover:text-white',
|
||||
'justify-start',
|
||||
link.disabled && 'text-muted-foreground hover:bg-transparent hover:text-muted-foreground'
|
||||
)
|
||||
"
|
||||
>
|
||||
<Icon :name="link.icon" class="mr-2 size-4" />
|
||||
{{ link.title }}
|
||||
<span
|
||||
v-if="link.label"
|
||||
:class="cn('ml-auto', link.variant === 'default' && 'text-background dark:text-white')"
|
||||
>
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
32
ui/src/components/sidebar/NavList.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import NavItem from '@/components/sidebar/NavItem.vue'
|
||||
|
||||
export interface LinkProp {
|
||||
title: string
|
||||
label?: string
|
||||
icon: string
|
||||
to: string
|
||||
variant: 'default' | 'ghost'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
isCollapsed: boolean
|
||||
links: LinkProp[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:data-collapsed="isCollapsed"
|
||||
class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2"
|
||||
>
|
||||
<nav
|
||||
class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"
|
||||
>
|
||||
<template v-for="(link, index) of links" :key="index">
|
||||
<NavItem :index="index" :link="link" :is-collapsed="isCollapsed" />
|
||||
</template>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
92
ui/src/components/sidebar/UserDropDown.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import TanView from '@/components/TanView.vue'
|
||||
import { Button, buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
import { CircleUser } from 'lucide-vue-next'
|
||||
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{
|
||||
isCollapsed: boolean
|
||||
}>()
|
||||
|
||||
const variant = 'secondary'
|
||||
|
||||
const {
|
||||
isPending,
|
||||
isError,
|
||||
data: user,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['user'],
|
||||
queryFn: () => pb.authStore.model
|
||||
})
|
||||
|
||||
const logout = () => {
|
||||
pb.authStore.clear()
|
||||
window.location.href = '/ui/login'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TanView :is-error="isError" :is-pending="isPending" :error="error" :value="user">
|
||||
<div class="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2">
|
||||
<nav
|
||||
class="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<div>
|
||||
<Tooltip v-if="isCollapsed" :delay-duration="0">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:class="
|
||||
cn(buttonVariants({ variant: variant, size: 'icon' }), 'mx-1 h-9 w-9 px-0')
|
||||
"
|
||||
>
|
||||
<CircleUser class="size-4" />
|
||||
<span class="sr-only">{{ user.name }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" class="flex items-center gap-4">
|
||||
{{ user.name }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
v-else
|
||||
:class="
|
||||
cn(buttonVariants({ variant: variant, size: 'sm' }), 'w-full justify-start')
|
||||
"
|
||||
>
|
||||
<CircleUser class="mr-2 size-4" />
|
||||
{{ user.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
@click="logout"
|
||||
class="cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</nav>
|
||||
</div>
|
||||
</TanView>
|
||||
</template>
|
||||
@@ -1,85 +0,0 @@
|
||||
<template>
|
||||
<v-list-item link dense>
|
||||
<v-list-item-content @click="goto">
|
||||
<v-list-item-title class="d-flex">
|
||||
<v-icon small class="mr-1">mdi-gauge</v-icon>
|
||||
<span class="text-truncate">{{ artifact.name }}</span>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="d-flex">
|
||||
<v-icon small class="mr-1" :color="statusColor">{{ statusIcon }}</v-icon>
|
||||
<span :class="statusColor + '--text'">{{ artifact.status | capitalize }}</span>
|
||||
|
||||
<v-icon small class="mx-1" :color="kindColor">{{ kindIcon }}</v-icon>
|
||||
<span :class="kindColor + '--text'">{{ artifact.kind | capitalize }}</span>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-icon small class="mr-1">mdi-information</v-icon>
|
||||
<span class="mr-1">{{ artifact.enrichments ? lodash.size(artifact.enrichments) : 0 }}</span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action v-if="action !== ''">
|
||||
<v-btn icon small>
|
||||
<v-icon small @click="actionClick">{{ action }}</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
import {Type, TypeColorEnum} from "../../client";
|
||||
|
||||
export default Vue.extend({
|
||||
name: "ArtifactSnippet",
|
||||
props: ["artifact", "to", "action"],
|
||||
computed: {
|
||||
statusIcon: function () {
|
||||
let icon = "mdi-help";
|
||||
this.lodash.forEach(this.$store.state.settings.artifactStates, (state: Type) => {
|
||||
if (this.artifact.status === state.id) {
|
||||
icon = state.icon;
|
||||
}
|
||||
})
|
||||
return icon;
|
||||
},
|
||||
statusColor: function () {
|
||||
let color = TypeColorEnum.Info as TypeColorEnum;
|
||||
this.lodash.forEach(this.$store.state.settings.artifactStates, (state: Type) => {
|
||||
if (this.artifact.status === state.id && state.color) {
|
||||
color = state.color;
|
||||
}
|
||||
})
|
||||
return color;
|
||||
},
|
||||
kindIcon: function () {
|
||||
let icon = "mdi-help";
|
||||
this.lodash.forEach(this.$store.state.settings.artifactKinds, (state: Type) => {
|
||||
if (this.artifact.kind === state.id) {
|
||||
icon = state.icon;
|
||||
}
|
||||
})
|
||||
return icon;
|
||||
},
|
||||
kindColor: function () {
|
||||
let color = TypeColorEnum.Info as TypeColorEnum;
|
||||
this.lodash.forEach(this.$store.state.settings.artifactKinds, (state: Type) => {
|
||||
if (this.artifact.kind === state.id && state.color) {
|
||||
color = state.color;
|
||||
}
|
||||
})
|
||||
return color;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
actionClick: function () {
|
||||
this.$emit("actionClick", this.artifact)
|
||||
},
|
||||
goto: function () {
|
||||
this.$emit("click", this.artifact)
|
||||
if (this.to) {
|
||||
this.$router.push(this.to);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,60 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<TicketSnippet v-if="ticket !== undefined" :ticket="ticket" :to="{ name: 'Ticket', params: { type: ticket.type, id: ticket.id } }"></TicketSnippet>
|
||||
<ArtifactSnippet v-if="artifact !== undefined" :artifact="artifact" :to="{ name: 'Artifact', params: { artifact: artifact.name } }"></ArtifactSnippet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
import {Artifact, TicketResponse} from "../../client";
|
||||
import {API} from "@/services/api";
|
||||
import TicketSnippet from "./TicketSnippet.vue";
|
||||
import ArtifactSnippet from "./ArtifactSnippet.vue";
|
||||
|
||||
interface State {
|
||||
ticket?: TicketResponse;
|
||||
artifact?: Artifact;
|
||||
}
|
||||
|
||||
export default Vue.extend({
|
||||
name: "IDSnippet",
|
||||
props: ["id"],
|
||||
data: (): State => ({
|
||||
ticket: undefined,
|
||||
artifact: undefined,
|
||||
}),
|
||||
components: {
|
||||
ArtifactSnippet,
|
||||
TicketSnippet
|
||||
},
|
||||
methods: {
|
||||
loadSnippet() {
|
||||
if (this.id.startsWith("tickets/")) {
|
||||
this.artifact = undefined;
|
||||
let ticketID = this.id.replace("tickets/", "")
|
||||
API.getTicket(ticketID).then(response => {
|
||||
this.ticket = response.data;
|
||||
});
|
||||
}
|
||||
if (this.id.startsWith("artifacts/")) {
|
||||
this.ticket = undefined;
|
||||
// TODO
|
||||
// let artifactID = this.id.replace("artifacts/", "")
|
||||
// API.getArtifact(artifactID).then(response => {
|
||||
// this.artifact = response.data;
|
||||
// });
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
"id": "loadSnippet",
|
||||
$route: "loadSnippet"
|
||||
},
|
||||
mounted() {
|
||||
this.loadSnippet();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,94 +0,0 @@
|
||||
<template>
|
||||
<v-list-item link dense>
|
||||
<v-list-item-content @click="goto">
|
||||
<v-list-item-title class="d-flex">
|
||||
<v-icon small class="mr-1">{{ typeIcon }}</v-icon>
|
||||
<span class="text-truncate">{{ ticket.type | capitalize }} #{{ ticket.id }}: {{ ticket.name }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-icon small class="mr-1">mdi-calendar-plus</v-icon>
|
||||
{{ ticket.created | formatdate($store.state.settings.timeformat) }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="d-flex">
|
||||
<v-icon small class="mr-1" :color="statusColor">{{ statusIcon }}</v-icon>
|
||||
<span :class="statusColor + '--text'">{{ ticket.status | capitalize }}</span>
|
||||
|
||||
<User :id="ticket.owner" :size="16" class="mx-2"></User>
|
||||
<v-spacer></v-spacer>
|
||||
<v-icon small class="mr-1">mdi-source-branch</v-icon>
|
||||
<span class="mr-1">{{ ticket.playbooks ? lodash.size(ticket.playbooks) : 0 }}</span>
|
||||
<v-icon small class="mr-1">mdi-checkbox-multiple-marked-outline</v-icon>
|
||||
<span class="mr-1">{{ opentaskcount }}</span>
|
||||
<v-icon small class="mr-1">mdi-comment</v-icon>
|
||||
<span class="mr-1">{{ ticket.comments ? ticket.comments.length : 0 }}</span>
|
||||
<v-icon small class="mr-1">mdi-file</v-icon>
|
||||
<span class="mr-1">{{ ticket.files ? ticket.files.length : 0 }}</span>
|
||||
<v-icon small class="mr-1">mdi-link</v-icon>
|
||||
<span class="mr-1">{{ ticket.references ? ticket.references.length : 0 }}</span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action v-if="action">
|
||||
<v-btn icon small>
|
||||
<v-icon small @click="actionClick">{{ action }}</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import Vue from "vue";
|
||||
import {Playbook, Task, Type, TypeColorEnum} from "@/client";
|
||||
import User from "@/components/User.vue";
|
||||
|
||||
export default Vue.extend({
|
||||
name: "TicketSnippet",
|
||||
props: ["ticket", "to", "action"],
|
||||
components: {
|
||||
User
|
||||
},
|
||||
computed: {
|
||||
opentaskcount: function() {
|
||||
let count = 0;
|
||||
this.lodash.forEach(this.ticket.playbooks, (playbook: Playbook) => {
|
||||
this.lodash.forEach(playbook.tasks, (task: Task) => {
|
||||
if (task.done) {
|
||||
count++;
|
||||
}
|
||||
})
|
||||
})
|
||||
return count;
|
||||
},
|
||||
typeIcon: function () {
|
||||
let icon = "mdi-help";
|
||||
this.lodash.forEach(this.$store.state.settings.ticketTypes, (ticketType: Type) => {
|
||||
if (this.ticket.type === ticketType.id) {
|
||||
icon = ticketType.icon
|
||||
}
|
||||
})
|
||||
return icon;
|
||||
},
|
||||
statusIcon: function() {
|
||||
if (this.ticket.status === 'closed') {
|
||||
return "mdi-checkbox-marked-circle-outline";
|
||||
}
|
||||
return "mdi-arrow-right-drop-circle-outline";
|
||||
},
|
||||
statusColor: function() {
|
||||
if (this.ticket.status === 'closed') {
|
||||
return TypeColorEnum.Success;
|
||||
}
|
||||
return TypeColorEnum.Info;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
actionClick: function () {
|
||||
this.$emit("actionClick", this.ticket)
|
||||
},
|
||||
goto: function () {
|
||||
if (this.to === undefined) {
|
||||
return
|
||||
}
|
||||
this.$router.push(this.to);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
28
ui/src/components/ticket/StatusIcon.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { Circle, CircleCheck, CircleMinus, CircleX } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
status: 'open' | 'completed' | 'pending' | 'failed'
|
||||
}>()
|
||||
|
||||
const strokeWidth = 2
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Circle v-if="status === 'open'" :stroke-width="strokeWidth" class="ml-2 h-4 w-4 text-gray-500" />
|
||||
<CircleCheck
|
||||
v-else-if="status === 'completed'"
|
||||
:stroke-width="strokeWidth"
|
||||
class="ml-2 h-4 w-4 text-emerald-500"
|
||||
/>
|
||||
<CircleMinus
|
||||
v-else-if="status === 'pending'"
|
||||
:stroke-width="strokeWidth"
|
||||
class="ml-2 h-4 w-4 text-amber-500"
|
||||
/>
|
||||
<CircleX
|
||||
v-else-if="status === 'failed'"
|
||||
:stroke-width="strokeWidth"
|
||||
class="ml-2 h-4 w-4 text-rose-500"
|
||||
/>
|
||||
</template>
|
||||
159
ui/src/components/ticket/TicketActionBar.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import TicketCloseDialog from '@/components/ticket/TicketCloseDialog.vue'
|
||||
import TicketDeleteDialog from '@/components/ticket/TicketDeleteDialog.vue'
|
||||
import TicketUserSelect from '@/components/ticket/TicketUserSelect.vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
import { Check, CircleDot, Repeat } from 'lucide-vue-next'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Ticket, Type } from '@/lib/types'
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
ticket: Ticket
|
||||
}>()
|
||||
|
||||
const {
|
||||
isPending,
|
||||
isError,
|
||||
data: types,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['types'],
|
||||
queryFn: (): Promise<Array<Type>> =>
|
||||
pb.collection('types').getFullList({
|
||||
sort: '-created'
|
||||
})
|
||||
})
|
||||
|
||||
const changeTypeMutation = useMutation({
|
||||
mutationFn: (typeID: string): Promise<Ticket> =>
|
||||
pb.collection('tickets').update(props.ticket.id, {
|
||||
type: typeID
|
||||
}),
|
||||
onSuccess: (data: Ticket) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] })
|
||||
router.push({ name: 'tickets', params: { type: data.type, id: props.ticket.id } })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: error.name,
|
||||
description: error.message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const closeTicketMutation = useMutation({
|
||||
mutationFn: (): Promise<Ticket> =>
|
||||
pb.collection('tickets').update(props.ticket.id, {
|
||||
open: !props.ticket.open
|
||||
}),
|
||||
onSuccess: (data: Ticket) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] })
|
||||
if (!data.open) {
|
||||
router.push({ name: 'tickets', params: { type: props.ticket.expand.type.id } })
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: error.name,
|
||||
description: error.message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const otherTypes = computed(() => types.value?.filter((t) => t.id !== props.ticket.expand.type.id))
|
||||
|
||||
const closeTicketDialogOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between bg-background p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" :disabled="!ticket">
|
||||
<Icon :name="ticket.expand.type.icon" class="mr-2 size-4" />
|
||||
{{ ticket.expand.type.singular }}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
v-for="type in otherTypes"
|
||||
:key="type.id"
|
||||
class="cursor-pointer"
|
||||
@click="changeTypeMutation.mutate(type.id)"
|
||||
>
|
||||
<Icon :name="type.icon" class="mr-2 size-4" />
|
||||
Convert to {{ type.singular }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Change Type</TooltipContent>
|
||||
</Tooltip>
|
||||
<TicketCloseDialog v-model="closeTicketDialogOpen" :ticket="ticket" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" :disabled="!ticket">
|
||||
<CircleDot v-if="ticket.open" class="mr-2 h-4 w-4" />
|
||||
<Check v-else class="mr-2 h-4 w-4" />
|
||||
{{ ticket?.open ? 'Open' : 'Closed' }}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
v-if="ticket.open"
|
||||
class="cursor-pointer"
|
||||
@click="closeTicketDialogOpen = true"
|
||||
>
|
||||
<Check class="mr-2 size-4" />
|
||||
Close Ticket
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-else class="cursor-pointer" @click="closeTicketMutation.mutate">
|
||||
<Repeat class="mr-2 size-4" />
|
||||
Reopen Ticket
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Change Status</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<div>
|
||||
<TicketUserSelect :key="ticket.owner" :uID="ticket.owner" :ticket="ticket" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Change User</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<TicketDeleteDialog :ticket="ticket" />
|
||||
</div>
|
||||
</template>
|
||||
66
ui/src/components/ticket/TicketCloseBar.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
import { Check, Repeat } from 'lucide-vue-next'
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Ticket } from '@/lib/types'
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
ticket: Ticket
|
||||
}>()
|
||||
|
||||
const resolution = ref(props.ticket.resolution)
|
||||
|
||||
const closeTicketMutation = useMutation({
|
||||
mutationFn: (): Promise<Ticket> =>
|
||||
pb.collection('tickets').update(props.ticket.id, {
|
||||
open: !props.ticket.open,
|
||||
resolution: resolution.value
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] })
|
||||
router.push({ name: 'tickets', params: { type: props.ticket.expand.type.id } })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: error.name,
|
||||
description: error.message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const closeButtonDisabled = false // computed(() => !props.ticket.open || message.value == '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-2 bg-background p-2">
|
||||
<Input v-if="ticket.open" v-model="resolution" placeholder="Closing reason" />
|
||||
<div v-else class="flex-1">
|
||||
<p class="ml-2 text-gray-500">Closed: {{ ticket.resolution }}</p>
|
||||
</div>
|
||||
<Button
|
||||
@click="closeTicketMutation.mutate"
|
||||
:disabled="closeButtonDisabled"
|
||||
:variant="closeButtonDisabled ? 'secondary' : 'default'"
|
||||
>
|
||||
<Check v-if="ticket.open" class="mr-2 h-4 w-4" />
|
||||
<Repeat v-else class="mr-2 h-4 w-4" />
|
||||
{{
|
||||
ticket?.open
|
||||
? 'Close ' + props.ticket.expand.type.singular
|
||||
: 'Reopen ' + props.ticket.expand.type.singular
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
75
ui/src/components/ticket/TicketCloseDialog.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Ticket } from '@/lib/types'
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
|
||||
const model = defineModel<boolean>()
|
||||
|
||||
const props = defineProps<{
|
||||
ticket: Ticket
|
||||
}>()
|
||||
|
||||
const resolution = ref(props.ticket.resolution)
|
||||
|
||||
const closeTicketMutation = useMutation({
|
||||
mutationFn: (): Promise<Ticket> =>
|
||||
pb.collection('tickets').update(props.ticket.id, {
|
||||
open: !props.ticket.open,
|
||||
resolution: resolution.value
|
||||
}),
|
||||
onSuccess: (data: Ticket) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] })
|
||||
if (!data.open) {
|
||||
router.push({ name: 'tickets', params: { type: props.ticket.expand.type.id } })
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: error.name,
|
||||
description: error.message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="model">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle> Close Ticket "{{ props.ticket.name }}"</DialogTitle>
|
||||
<DialogDescription> Are you sure you want to close this ticket?</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Textarea v-model="resolution" placeholder="Closing reason" />
|
||||
|
||||
<DialogFooter class="mt-2">
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary"> Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" variant="default" @click="closeTicketMutation.mutate()">
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
90
ui/src/components/ticket/TicketDeleteDialog.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
import { MoreVertical, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Ticket } from '@/lib/types'
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
ticket: Ticket
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
const deleteTicketMutation = useMutation({
|
||||
mutationFn: (): Promise<boolean> => pb.collection('tickets').delete(props.ticket.id),
|
||||
onSuccess: () => {
|
||||
router.push({ name: 'tickets', params: { type: props.ticket.expand.type.id } })
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] })
|
||||
isOpen.value = false
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({
|
||||
title: error.name,
|
||||
description: error.message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" :disabled="!ticket">
|
||||
<MoreVertical class="size-4" />
|
||||
<span class="sr-only">More</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem class="cursor-pointer" as-child>
|
||||
<DialogTrigger class="w-full">
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Delete Ticket
|
||||
</DialogTrigger>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle> Delete Ticket "{{ props.ticket.name }}"</DialogTitle>
|
||||
<DialogDescription> Are you sure you want to delete this ticket?</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter class="mt-2">
|
||||
<DialogClose as-child>
|
||||
<Button type="button" variant="secondary"> Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" variant="destructive" @click="deleteTicketMutation.mutate()">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
215
ui/src/components/ticket/TicketDisplay.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<script lang="ts" setup>
|
||||
import TanView from '@/components/TanView.vue'
|
||||
import JSONSchemaFormFields from '@/components/form/JSONSchemaFormFields.vue'
|
||||
import DynamicMDEditor from '@/components/input/DynamicMDEditor.vue'
|
||||
import StatusIcon from '@/components/ticket/StatusIcon.vue'
|
||||
import TicketActionBar from '@/components/ticket/TicketActionBar.vue'
|
||||
import TicketCloseBar from '@/components/ticket/TicketCloseBar.vue'
|
||||
import TicketHeader from '@/components/ticket/TicketHeader.vue'
|
||||
import TicketTab from '@/components/ticket/TicketTab.vue'
|
||||
import TicketComments from '@/components/ticket/comment/TicketComments.vue'
|
||||
import TicketFiles from '@/components/ticket/file/TicketFiles.vue'
|
||||
import TicketLinks from '@/components/ticket/link/TicketLinks.vue'
|
||||
import TicketTasks from '@/components/ticket/task/TicketTasks.vue'
|
||||
import TicketTimeline from '@/components/ticket/timeline/TicketTimeline.vue'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
import { Edit } from 'lucide-vue-next'
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Ticket, Type } from '@/lib/types'
|
||||
|
||||
const route = useRoute()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
defineProps<{
|
||||
selectedType: Type
|
||||
}>()
|
||||
|
||||
const id = computed(() => route.params.id as string)
|
||||
|
||||
const message = ref('')
|
||||
const editMode = ref(false)
|
||||
|
||||
const {
|
||||
isPending,
|
||||
isError,
|
||||
data: ticket,
|
||||
error
|
||||
} = useQuery({
|
||||
queryKey: ['tickets', id.value],
|
||||
queryFn: (): Promise<Ticket> =>
|
||||
pb.collection('tickets').getOne(id.value, {
|
||||
expand:
|
||||
'type,owner,comments_via_ticket.author,files_via_ticket,timeline_via_ticket,links_via_ticket,tasks_via_ticket.owner'
|
||||
})
|
||||
})
|
||||
|
||||
const editDescriptionMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
pb.collection('tickets').update(id.value, {
|
||||
description: message.value
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets', id.value] })
|
||||
editMode.value = false
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({
|
||||
title: error.name,
|
||||
description: error.message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
})
|
||||
|
||||
const edit = () => (editMode.value = true)
|
||||
|
||||
const editStateMutation = useMutation({
|
||||
mutationFn: (state: Record<string, any>): Promise<Ticket> =>
|
||||
pb.collection('tickets').update(id.value, {
|
||||
state: state
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tickets', id.value] }),
|
||||
onError: (error) =>
|
||||
toast({
|
||||
title: error.name,
|
||||
description: error.message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
})
|
||||
|
||||
const taskStatus = computed(() => {
|
||||
if (!ticket.value) {
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const tasks = ticket.value.expand.tasks_via_ticket
|
||||
|
||||
if (tasks.every((task) => !task.open)) {
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
if (tasks.every((task) => task.open)) {
|
||||
return 'open'
|
||||
}
|
||||
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const updateDescription = (value: string) => (message.value = value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TanView :isError="isError" :isPending="isPending" :error="error" :value="ticket">
|
||||
<div v-if="ticket" class="flex h-full flex-col">
|
||||
<TicketActionBar :ticket="ticket" />
|
||||
<Separator />
|
||||
<div class="flex w-full max-w-7xl flex-1 flex-col overflow-hidden xl:m-auto xl:flex-row">
|
||||
<div class="flex flex-1 flex-col gap-4 px-4 pt-4">
|
||||
<TicketHeader :ticket="ticket" />
|
||||
<Card class="relative p-4">
|
||||
<Button v-if="!editMode" variant="outline" class="float-right h-8 gap-2" @click="edit">
|
||||
<Edit class="h-3.5 w-3.5" />
|
||||
<span>Edit</span>
|
||||
</Button>
|
||||
<DynamicMDEditor
|
||||
:modelValue="ticket.description"
|
||||
@update:modelValue="updateDescription"
|
||||
v-model:edit="editMode"
|
||||
autofocus
|
||||
placeholder="Type a description..."
|
||||
@save="editDescriptionMutation.mutate"
|
||||
class="min-h-14"
|
||||
/>
|
||||
</Card>
|
||||
<Separator />
|
||||
<Tabs default-value="timeline" class="flex flex-1 flex-col overflow-hidden">
|
||||
<TabsList>
|
||||
<TabsTrigger value="timeline">
|
||||
Timeline
|
||||
<Badge
|
||||
v-if="
|
||||
ticket.expand.timeline_via_ticket &&
|
||||
ticket.expand.timeline_via_ticket.length > 0
|
||||
"
|
||||
variant="outline"
|
||||
class="ml-2"
|
||||
>
|
||||
{{
|
||||
ticket.expand.timeline_via_ticket ? ticket.expand.timeline_via_ticket.length : 0
|
||||
}}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks">
|
||||
Tasks
|
||||
<Badge
|
||||
v-if="ticket.expand.tasks_via_ticket && ticket.expand.tasks_via_ticket.length > 0"
|
||||
variant="outline"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ ticket.expand.tasks_via_ticket ? ticket.expand.tasks_via_ticket.length : 0 }}
|
||||
<StatusIcon :status="taskStatus" class="size-6" />
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="comments">
|
||||
Comments
|
||||
<Badge
|
||||
v-if="
|
||||
ticket.expand.comments_via_ticket &&
|
||||
ticket.expand.comments_via_ticket.length > 0
|
||||
"
|
||||
variant="outline"
|
||||
class="ml-2"
|
||||
>
|
||||
{{
|
||||
ticket.expand.comments_via_ticket ? ticket.expand.comments_via_ticket.length : 0
|
||||
}}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TicketTab value="timeline">
|
||||
<TicketTimeline :ticket="ticket" :timeline="ticket.expand.timeline_via_ticket" />
|
||||
</TicketTab>
|
||||
<TicketTab value="tasks">
|
||||
<TicketTasks :ticket="ticket" :tasks="ticket.expand.tasks_via_ticket" />
|
||||
</TicketTab>
|
||||
<TicketTab value="comments">
|
||||
<TicketComments :ticket="ticket" :comments="ticket.expand.comments_via_ticket" />
|
||||
</TicketTab>
|
||||
</Tabs>
|
||||
<Separator class="xl:hidden" />
|
||||
</div>
|
||||
<ScrollArea>
|
||||
<div class="flex flex-initial flex-col gap-4 p-4 xl:w-96">
|
||||
<div>
|
||||
<div class="flex h-10 flex-row items-center justify-between text-muted-foreground">
|
||||
<span class="text-sm font-semibold"> Details </span>
|
||||
</div>
|
||||
<JSONSchemaFormFields
|
||||
:modelValue="ticket.state"
|
||||
@update:modelValue="editStateMutation.mutate"
|
||||
:schema="selectedType.schema"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<TicketLinks :ticket="ticket" :links="ticket.expand.links_via_ticket" />
|
||||
<Separator />
|
||||
<TicketFiles :ticket="ticket" :files="ticket.expand.files_via_ticket" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<Separator />
|
||||
<TicketCloseBar :ticket="ticket" />
|
||||
</div>
|
||||
</TanView>
|
||||
</template>
|
||||
57
ui/src/components/ticket/TicketHeader.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import DynamicInput from '@/components/input/DynamicInput.vue'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import format from 'date-fns/format'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { pb } from '@/lib/pocketbase'
|
||||
import type { Ticket, Type } from '@/lib/types'
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = defineProps<{
|
||||
ticket: Ticket
|
||||
}>()
|
||||
|
||||
const name = ref(props.ticket.name)
|
||||
|
||||
const editNameMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
pb.collection('tickets').update(props.ticket.id, {
|
||||
name: name.value
|
||||
}),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tickets', props.ticket.id] }),
|
||||
onError: (error) =>
|
||||
toast({
|
||||
title: error.name,
|
||||
description: error.message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
})
|
||||
|
||||
const updateName = (value: string) => {
|
||||
name.value = value
|
||||
editNameMutation.mutate()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="text-4xl font-bold">
|
||||
<DynamicInput :modelValue="ticket.name" @update:modelValue="updateName" class="-mx-1" />
|
||||
</span>
|
||||
|
||||
<div class="flex flex-row space-x-2 px-1 text-xs">
|
||||
<div class="flex items-center gap-1 text-muted-foreground">
|
||||
Created:
|
||||
{{ format(new Date(ticket.created), 'PPpp') }}
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div class="flex items-center gap-1 text-muted-foreground">
|
||||
Updated:
|
||||
{{ format(new Date(ticket.updated), 'PPpp') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||