refactor: improve setup and maintainability (#1067)

This commit is contained in:
Jonas Plum
2024-07-08 00:16:37 +02:00
committed by GitHub
parent f5fcee0096
commit 619c5c65ce
553 changed files with 11271 additions and 91670 deletions
-48
View File
@@ -1,48 +0,0 @@
<template>
<v-container>
<v-card style="background-color: #fff;" class="mt-8">
<div class="swagger" id="swagger" ></div>
</v-card>
</v-container>
</template>
<script lang="ts">
import SwaggerUI from 'swagger-ui';
import 'swagger-ui/dist/swagger-ui.css';
import Vue from "vue";
import spec from '../../../generated/community.json';
export default Vue.extend({
name: "API",
components: {},
mounted() {
SwaggerUI({
spec: spec,
dom_id: '#swagger',
oauth2RedirectUrl: location.href
})
}
});
</script>
<style>
#swagger a,
#swagger h1,
#swagger h2,
#swagger h3,
#swagger h4,
#swagger h5,
#swagger h6 {
color: black !important
}
#swagger .info {
padding: 0 10px 10px;
border-radius: 4px;
}
#swagger .servers-title,
#swagger .servers {
display: none;
}
</style>
-325
View File
@@ -1,325 +0,0 @@
<template>
<v-container>
<h1>{{ name }}</h1>
<v-divider class="my-2"></v-divider>
<h2 class="text--disabled" style="font-size: 12pt" v-if="artifact">
Status:
<v-menu offset-y class="mr-2">
<template v-slot:activator="{ on, attrs }">
<span v-bind="attrs" v-on="on">
<v-icon small class="mr-1" :color="statusColor(artifact.status)">{{ statusIcon(artifact.status) }}</v-icon>
<span :class="statusColor(artifact.status) + '--text'">{{ artifact.status | capitalize }}</span>
</span>
</template>
<v-list>
<v-list-item dense link v-for="state in otherStates" :key="state.id" @click="setStatus(state.id)">
<v-list-item-title>
Set status to <v-icon small>{{ statusIcon(state.id) }}</v-icon> {{ state.name }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
&middot;
Kind:
<v-menu offset-y class="mr-2">
<template v-slot:activator="{ on, attrs }">
<span v-bind="attrs" v-on="on">
<v-icon small class="mr-1" :color="kindColor(artifact.kind)">{{ kindIcon(artifact.kind) }}</v-icon>
<span :class="kindColor(artifact.kind) + '--text'">{{ artifact.kind | capitalize }}</span>
</span>
</template>
<v-list>
<v-list-item dense link v-for="state in otherKinds" :key="state.id" @click="setKind(state.id)">
<v-list-item-title>
Set kind to <v-icon small>{{ kindIcon(state.id) }}</v-icon> {{ state.name }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
&middot;
Type:
<v-menu
:close-on-content-click="false"
offset-y
class="mr-2">
<template v-slot:activator="{ on, attrs }">
<span v-bind="attrs" v-on="on">
{{ artifact.type ? artifact.type : "empty" }}
</span>
</template>
<v-combobox
class="pt-6 pb-0 px-3"
style="background-color: white"
v-model="artifacttype"
:items="['ip', 'domain', 'md5', 'sha1', 'sha256', 'filename', 'url']"
label="Artifact Type"
@change="setArtifactType"
></v-combobox>
</v-menu>
</h2>
<v-divider class="mt-0 mb-4"></v-divider>
<div v-if="automations">
<v-card
v-for="automation in artifactautomations" :key="automation.id" class="mb-4" elevation="0" outlined>
<v-card-title>
{{ automation.id }}
<v-spacer></v-spacer>
<v-btn
v-if="artifact && artifact.enrichments && automation.id in artifact.enrichments"
@click="enrich(automation.id)"
elevation="0"
>
<v-icon>mdi-sync</v-icon>
</v-btn>
<v-btn v-else @click="enrich(automation.id)" elevation="0">
<v-icon>mdi-play</v-icon>
</v-btn>
</v-card-title>
<v-card-text v-if="artifact && artifact.enrichments && automation.id in artifact.enrichments && artifact.enrichments[automation.id].data">
<div class="template" v-if="artifact.enrichments[automation.id].html" v-html="artifact.enrichments[automation.id].html"></div>
<JSONHTML v-else :json="artifact.enrichments[automation.id].data"></JSONHTML>
</v-card-text>
</v-card>
</div>
<div v-if="artifact">
<div v-for="enrichment in artifact.enrichments" :key="enrichment.name" >
<v-card v-if="!hasautomation(enrichment.name)" outlined>
<v-card-title>
{{ enrichment.name }}
</v-card-title>
<v-card-text>
<vue-markdown>
{{ enrichment.data }}
</vue-markdown>
</v-card-text>
</v-card>
</div>
</div>
</v-container>
</template>
<script lang="ts">
import Vue from "vue";
import {
Artifact,
AutomationResponse, AutomationResponseTypeEnum, AutomationTypeEnum,
Type,
TypeColorEnum
} from "@/client";
import VueMarkdown from "vue-markdown";
import { API } from "@/services/api";
import JSONHTML from "../components/JSONHTML.vue";
interface State {
artifact?: Artifact;
automations?: Array<AutomationResponse>;
artifacttype: string;
}
export default Vue.extend({
name: "ArtifactPopup",
components: {
"vue-markdown": VueMarkdown,
JSONHTML,
},
props: ["id", "name"],
data: (): State => ({
artifact: undefined,
automations: undefined,
artifacttype: "unknown",
}),
watch: {
id: function(): void {
this.load();
},
name: function(): void {
this.load();
}
},
computed: {
artifactautomations: function (): Array<AutomationResponse> {
if (!this.automations) {
return [];
}
return this.lodash.filter(this.automations, (automation: AutomationResponse) => {
if (!automation || !automation.type) {
return true;
}
return this.lodash.includes(automation.type, AutomationResponseTypeEnum.Artifact)
})
},
ticketID(): number {
return parseInt(this.id, 10);
},
otherStates: function (): Array<Type> {
return this.lodash.filter(this.$store.state.settings.artifactStates, (state: Type) => {
if (!this.artifact || !this.artifact.status) {
return true;
}
return state.id !== this.artifact.status;
})
},
otherKinds: function (): Array<Type> {
return this.lodash.filter(this.$store.state.settings.artifactKinds, (state: Type) => {
if (!this.artifact || !this.artifact.status) {
return true;
}
return state.id !== this.artifact.status;
})
},
},
methods: {
setArtifactType() {
if (!this.artifact || !this.artifact.name || this.ticketID === undefined) {
return;
}
let artifact = this.artifact
artifact.type = this.artifacttype
API.setArtifact(this.ticketID, this.artifact.name, artifact).then((response) => {
this.$store.dispatch("alertSuccess", { name: "Artifact type changed", type: "success" })
if (response.data.artifacts) {
this.lodash.forEach(response.data.artifacts, (artifact) => {
if (artifact.name == this.name) {
this.artifact = artifact;
}
})
}
});
},
hasautomation(name: string): boolean {
let found = false;
this.lodash.forEach(this.automations, (automation) => {
if (automation.id === name) {
found = true
}
})
return found;
},
loadArtifact(id: number, artifact: string) {
API.getArtifact(id, artifact).then((response) => {
this.artifact = response.data;
});
},
loadAutomations() {
API.listAutomations().then((response) => {
this.automations = response.data;
});
},
enrich(automation: string) {
if (this.artifact === undefined || this.ticketID === undefined) {
return
}
API.runArtifact(this.ticketID, this.name, automation);
},
statusIcon: function (status: string): string {
let icon = "mdi-help";
this.lodash.forEach(this.$store.state.settings.artifactStates, (state: Type) => {
if (status === state.id) {
icon = state.icon;
}
})
return icon;
},
statusColor: function (status: string) {
let color = TypeColorEnum.Info as TypeColorEnum;
this.lodash.forEach(this.$store.state.settings.artifactStates, (state: Type) => {
if (status === state.id && state.color) {
color = state.color
}
})
return color;
},
setStatus(status: string) {
if (!this.artifact || !this.artifact.name || this.ticketID === undefined) {
return;
}
let artifact = this.artifact
artifact.status = status
API.setArtifact(this.ticketID, this.artifact.name, artifact).then((response) => {
this.$store.dispatch("alertSuccess", { name: "Artifact status changed", type: "success" })
if (response.data.artifacts) {
this.lodash.forEach(response.data.artifacts, (artifact) => {
if (artifact.name == this.name) {
this.artifact = artifact;
}
})
}
});
},
kindIcon: function (kind: string): string {
let icon = "mdi-help";
this.lodash.forEach(this.$store.state.settings.artifactKinds, (state: Type) => {
if (kind === state.id) {
icon = state.icon;
}
})
return icon;
},
kindColor: function (kind: string) {
let color = TypeColorEnum.Info as TypeColorEnum;
this.lodash.forEach(this.$store.state.settings.artifactKinds, (state: Type) => {
if (kind === state.id && state.color) {
color = state.color
}
})
return color;
},
setKind(kind: string) {
if (!this.artifact || !this.artifact.name || this.ticketID === undefined) {
return;
}
let artifact = this.artifact
artifact.kind = kind
API.setArtifact(this.ticketID, this.artifact.name, artifact).then((response) => {
this.$store.dispatch("alertSuccess", { name: "Artifact kind changed", type: "success" })
if (response.data.artifacts) {
this.lodash.forEach(response.data.artifacts, (artifact) => {
if (artifact.name == this.name) {
this.artifact = artifact;
}
})
}
});
},
load() {
this.loadArtifact(this.ticketID, this.name);
}
},
mounted(): void {
this.load();
this.loadAutomations();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (id === "tickets/" + this.ticketID) {
this.load();
}
});
})
},
});
</script>
<style>
.template {
overflow: auto;
}
.template table, .template th, .template td {
border: 1px solid #777 !important;
border-left: 0 !important;
border-right: 0 !important;
padding: 4px !important;
}
</style>
-138
View File
@@ -1,138 +0,0 @@
<template>
<div v-if="automation !== undefined" class="flex-grow-1 flex-column d-flex fill-height pa-8">
<v-row class="flex-grow-0 flex-shrink-0">
<v-spacer></v-spacer>
<v-btn href="https://catalyst-soar.com/docs/catalyst/engineer/automations" target="_blank" outlined rounded small>
<v-icon>mdi-book-open</v-icon> Handbook
</v-btn>
</v-row>
<v-alert v-if="readonly" type="info">You do not have write access to automations.</v-alert>
<h2 v-if="readonly">Automation: {{ automation.id }}</h2>
<h2 v-else-if="this.$route.params.id === 'new'">New Automation: {{ automation.id }}</h2>
<h2 v-else>Edit Automation: {{ automation.id }}</h2>
<v-row class="flex-grow-0 flex-shrink-0">
<v-col cols="12">
<v-text-field :readonly="readonly" label="ID" v-model="automation.id" class="flex-grow-0 flex-shrink-0"></v-text-field>
<v-select
label="Type"
:items="types"
item-text="id"
return-object
multiple
v-model="automation.type"
></v-select>
<v-text-field :readonly="readonly" label="Docker Image" v-model="automation.image" class="flex-grow-0 flex-shrink-0"></v-text-field>
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">Script</v-subheader>
<div class="flex-grow-1 flex-shrink-1 overflow-scroll">
<Editor v-model="automation.script" lang="python" :readonly="readonly" ></Editor>
</div>
<AdvancedJSONSchemaEditor
@save="save"
:schema="automation.schema ? JSON.parse(automation.schema) : {}"
hidepreview
class="mt-4"
:readonly="readonly"></AdvancedJSONSchemaEditor>
</v-col>
</v-row>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import Editor from "../components/Editor.vue";
import AdvancedJSONSchemaEditor from "../components/AdvancedJSONSchemaEditor.vue";
import {API} from "@/services/api";
import {AutomationResponse, AutomationForm, AutomationResponseTypeEnum} from "@/client";
import {DateTime} from "luxon";
interface State {
automation?: AutomationResponse;
data?: any;
valid: boolean;
}
export default Vue.extend({
name: "Automation",
components: { Editor, AdvancedJSONSchemaEditor },
data: (): State => ({
automation: undefined,
data: undefined,
valid: true,
}),
watch: {
$route: function () {
this.loadAutomation();
},
},
computed: {
readonly: function (): boolean {
return !this.hasRole("automation:write");
},
types: function (): Array<string> {
return [ AutomationResponseTypeEnum.Global, AutomationResponseTypeEnum.Playbook, AutomationResponseTypeEnum.Artifact ]
}
},
methods: {
save(schema) {
if (this.automation === undefined) {
return;
}
let automation = this.automation as any as AutomationForm;
automation.schema = schema;
if (this.$route.params.id == "new") {
API.createAutomation(automation);
} else {
API.updateAutomation(this.$route.params.id, automation);
}
},
loadAutomation() {
if (!this.$route.params.id) {
return;
}
if (this.$route.params.id == "new") {
this.automation = { id: "my-automation", image: "docker.io/ubuntu", script: "", type: [ AutomationResponseTypeEnum.Global, AutomationResponseTypeEnum.Playbook, AutomationResponseTypeEnum.Artifact ] };
} else {
API.getAutomation(this.$route.params.id).then((response) => {
this.automation = response.data;
});
}
},
hasRole: function (s: string): boolean {
if (this.$store.state.settings.roles) {
return this.lodash.includes(this.$store.state.settings.roles, s);
}
return false;
},
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);
},
},
mounted() {
this.loadAutomation();
},
});
</script>
-64
View File
@@ -1,64 +0,0 @@
<template>
<v-main style="min-height: 100vh">
<List
:items="automations"
routername="Automation"
itemid="id"
itemname="id"
singular="Automation"
plural="Automations"
writepermission="automation:write"
@delete="deleteAutomation"
></List>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {AutomationResponse} from "@/client";
import {API} from "@/services/api";
import List from "../components/List.vue";
interface State {
automations: Array<AutomationResponse>;
}
export default Vue.extend({
name: "AutomationList",
components: {List},
data: (): State => ({
automations: [],
}),
methods: {
loadAutomations() {
API.listAutomations().then((response) => {
this.automations = response.data;
});
},
deleteAutomation(name: string) {
API.deleteAutomation(name).then(() => {
this.loadAutomations();
});
},
},
mounted() {
this.loadAutomations();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "automations/")) {
reload = true;
}
});
if (reload) {
this.loadAutomations()
}
})
},
});
</script>
-254
View File
@@ -1,254 +0,0 @@
<template>
<div v-if="dashboard">
<h2 class="d-flex">
<span v-if="!editmode">{{ dashboard.name }}</span>
<v-text-field v-else v-model="dashboard.name" outlined dense class="mb-0" hide-details></v-text-field>
<v-spacer></v-spacer>
<v-btn v-if="editmode" small outlined @click="addWidget" class="mr-1">
<v-icon>mdi-plus</v-icon>
Add Widget
</v-btn>
<v-btn v-if="editmode" small outlined @click="save" class="mr-1">
<v-icon>mdi-content-save</v-icon>
Save
</v-btn>
<v-btn v-if="editmode && $route.params.id !== 'new'" small outlined @click="cancel">
<v-icon>mdi-cancel</v-icon>
Cancel
</v-btn>
<v-btn v-if="!editmode" small outlined @click="edit">
<v-icon>mdi-pencil</v-icon>
Edit
</v-btn>
</h2>
<v-row>
<v-col v-for="(widget, index) in dashboard.widgets" :key="index" :cols="widget.width">
<v-card class="mb-2">
<v-card-title>
<span v-if="!editmode">{{ widget.name }}</span>
<v-text-field v-else outlined dense hide-details v-model="widget.name" class="mr-1"></v-text-field>
<v-btn v-if="editmode" outlined @click="removeWidget(index)">
<v-icon>mdi-close</v-icon>
Remove
</v-btn>
</v-card-title>
<v-card-text v-if="editmode">
<v-row>
<v-col cols="8">
<v-select label="Type" v-model="widget.type" :items="['line', 'bar', 'pie']"></v-select>
</v-col>
<v-col cols="4">
<v-text-field label="Width" type="number" v-model="widget.width"></v-text-field>
</v-col>
</v-row>
<v-text-field label="Aggregation" v-model="widget.aggregation"></v-text-field>
<v-text-field label="Filter" v-model="widget.filter" clearable></v-text-field>
</v-card-text>
<v-card-text v-if="data[index] === null">
{{ widgetErrors[index] }}
</v-card-text>
<div v-else>
<line-chart
v-if="widget.type === 'line' && data[index]"
:chart-data="data[index]"
:styles="{ width: '100%', position: 'relative' }"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
legend: false,
scales: { yAxes: [ { ticks: { beginAtZero: true, precision: 0 } } ] }
}"
>
</line-chart>
<pie-chart
v-if="widget.type === 'pie' && data[index]"
:chart-data="data[index]"
:styles="{ width: '100%', position: 'relative' }"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
}"
>
</pie-chart>
<bar-chart
v-if="widget.type === 'bar' && data[index]"
:chart-data="data[index]"
:styles="{
width: '100%',
'max-height': '400px',
position: 'relative'
}"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
legend: false,
scales: { xAxes: [ { ticks: { beginAtZero: true, precision: 0 } } ] },
}"
></bar-chart>
</div>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import {DashboardResponse, Widget} from "@/client";
import { API } from "@/services/api";
import {createHash} from "crypto";
import {colors} from "@/plugins/vuetify";
import LineChart from "../components/charts/Line";
import BarChart from "../components/charts/Bar";
import PieChart from "../components/charts/Doughnut";
import {ChartData} from "chart.js";
import {AxiosError, AxiosResponseTransformer} from "axios";
interface State {
dashboard?: DashboardResponse;
undodashboard?: DashboardResponse;
data: Record<string, any>;
editmode: boolean;
widgetErrors: Record<number, string>;
}
export default Vue.extend({
name: "Dashboard",
components: {
LineChart,
BarChart,
PieChart,
},
data: (): State => ({
dashboard: undefined,
undodashboard: undefined,
data: {},
editmode: false,
widgetErrors: {},
}),
watch: {
$route: function () {
this.loadDashboard();
},
},
methods: {
edit: function () {
this.undodashboard = this.lodash.cloneDeep(this.dashboard);
this.editmode = true;
},
save: function () {
if (!this.dashboard) {
return
}
let widgets = [] as Array<Widget>;
this.lodash.forEach(this.dashboard.widgets, (widget) => {
widget.width = this.lodash.toInteger(widget.width);
if (!widget.filter) {
this.lodash.unset(widget, "filter")
}
widgets.push(widget);
})
this.dashboard.widgets = widgets;
if (this.$route.params.id === 'new') {
API.createDashboard(this.dashboard).then((response) => {
this.loadWidgetData(response.data.widgets);
this.dashboard = response.data;
this.editmode = false;
this.$router.push({ name: "Dashboard", params: { id: response.data.id }})
})
} else {
API.updateDashboard(this.dashboard.id, this.dashboard).then((response) => {
this.loadWidgetData(response.data.widgets);
this.dashboard = response.data;
this.editmode = false;
})
}
},
cancel: function () {
this.dashboard = this.lodash.cloneDeep(this.undodashboard);
this.editmode = false;
},
addWidget: function () {
if (!this.dashboard) {
return
}
this.dashboard.widgets.push({name: "new widget", width: 6, aggregation: "", type: "line"})
},
removeWidget: function (id: number) {
if (!this.dashboard) {
return
}
console.log(id);
let widgets = this.lodash.cloneDeep(this.dashboard.widgets);
this.lodash.pullAt(widgets, [id]);
Vue.set(this.dashboard, "widgets", widgets);
},
loadDashboard: function () {
if (this.$route.params.id === 'new') {
this.dashboard = {
name: "New dashboard",
widgets: [{name: "new widget", width: 6, aggregation: "", type: "line"}],
} as DashboardResponse
this.editmode = true;
} else {
API.getDashboard(this.$route.params.id).then((response) => {
this.loadWidgetData(response.data.widgets);
this.dashboard = response.data;
});
}
},
loadWidgetData: function (widgets: Array<Widget>) {
this.lodash.forEach(widgets, (widget: Widget, index: number) => {
let widgetErrors = {};
let defaultTransformers = this.axios.defaults.transformResponse as AxiosResponseTransformer[]
let transformResponse = defaultTransformers.concat((data) => {
data.notoast = true;
return data
});
API.dashboardData(widget.aggregation, widget.filter, {transformResponse: transformResponse}).then((response) => {
let d = { labels: [], datasets: [{data: [], backgroundColor: []}] } as ChartData;
this.lodash.forEach(response.data, (v: any, k: string) => {
// @ts-expect-error T2532
d.labels.push(k)
// @ts-expect-error T2532
d.datasets[0].data.push(v)
if (widget.type !== 'line') {
// @ts-expect-error T2532
d.datasets[0].backgroundColor.push(this.color(this.lodash.toString(v)));
}
})
Vue.set(this.data, index, d);
}).catch((err: AxiosError) => {
widgetErrors[index] = this.lodash.toString(err.response?.data.error);
Vue.set(this.data, index, null);
})
Vue.set(this, 'widgetErrors', widgetErrors);
})
},
color: function (s: string): string {
let pos = createHash('md5').update(s).digest().readUInt32BE(0) % colors.length;
return colors[pos];
},
},
mounted() {
this.loadDashboard();
}
});
</script>
-64
View File
@@ -1,64 +0,0 @@
<template>
<v-main style="min-height: 100vh;">
<List
:items="dashboards"
routername="Dashboard"
itemid="id"
itemname="name"
singular="Dashboard"
plural="Dashboards"
writepermission="dashboard:write"
@delete="deleteDashboard"
></List>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {Dashboard} from "@/client";
import {API} from "@/services/api";
import List from "../components/List.vue";
interface State {
dashboards: Array<Dashboard>;
}
export default Vue.extend({
name: "DashboardList",
components: {List},
data: (): State => ({
dashboards: [],
}),
methods: {
loadDashboards() {
API.listDashboards().then((response) => {
this.dashboards = response.data;
});
},
deleteDashboard(id: string) {
API.deleteDashboard(id).then(() => {
this.loadDashboards();
})
}
},
mounted() {
this.loadDashboards();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "dashboard/")) {
reload = true;
}
});
if (reload) {
this.loadDashboards()
}
})
},
});
</script>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import OpenTasks from '@/components/dashboard/OpenTasks.vue'
import OpenTickets from '@/components/dashboard/OpenTickets.vue'
import TicketOverTime from '@/components/dashboard/TicketOverTime.vue'
import TicketTypes from '@/components/dashboard/TicketTypes.vue'
import TwoColumn from '@/components/layout/TwoColumn.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { ExternalLink } from 'lucide-vue-next'
import { useQuery } from '@tanstack/vue-query'
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase'
const router = useRouter()
const {
isPending,
isError,
data: dashboardCounts,
error
} = useQuery({
queryKey: ['dashboard_counts'],
queryFn: (): Promise<Array<any>> => pb.collection('dashboard_counts').getFullList()
})
const count = (id: string) => {
if (!dashboardCounts.value) return 0
const s = dashboardCounts.value.filter((stat) => stat.id === id)
if (s.length === 0) return 0
return s[0].count
}
onMounted(() => {
if (!pb.authStore.model) {
router.push({ name: 'login' })
}
})
</script>
<template>
<TwoColumn>
<div class="flex h-screen flex-1 flex-col">
<div class="flex h-14 items-center bg-background px-4 py-2">
<h1 class="text-xl font-bold">Dashboard</h1>
</div>
<Separator class="shrink-0" />
<ScrollArea>
<div
class="m-auto grid max-w-7xl grid-cols-1 grid-rows-[100px_100px_100px_100px] gap-4 p-4 md:grid-cols-2 md:grid-rows-[100px_100px] xl:grid-cols-4 xl:grid-rows-[100px]"
>
<Card>
<CardHeader>
<CardTitle>{{ count('tasks') }}</CardTitle>
<CardDescription>Tasks</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ count('tickets') }}</CardTitle>
<CardDescription>Tickets</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ count('users') }}</CardTitle>
<CardDescription>Users</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle> Catalyst</CardTitle>
</CardHeader>
<CardContent class="flex flex-1 flex-col gap-1">
<a
href="https://catalyst-soar.com/docs/category/catalyst-handbook"
target="_blank"
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
>
Open Catalyst Handbook
<ExternalLink class="ml-2 h-4 w-4" />
</a>
<a
href="/_/"
target="_blank"
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
>
Open Admin Interface
<ExternalLink class="ml-2 h-4 w-4" />
</a>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> Tickets by Type</CardTitle>
</CardHeader>
<CardContent>
<TicketTypes />
</CardContent>
</Card>
<Card class="xl:col-span-2">
<CardHeader>
<CardTitle>Tickets Per Week</CardTitle>
</CardHeader>
<CardContent>
<TicketOverTime />
</CardContent>
</Card>
<Card class="xl:col-span-2">
<CardHeader>
<CardTitle>Your Open Tickets</CardTitle>
</CardHeader>
<CardContent>
<OpenTickets />
</CardContent>
</Card>
<Card class="xl:col-span-2">
<CardHeader>
<CardTitle>Your Open Tasks</CardTitle>
</CardHeader>
<CardContent>
<OpenTasks />
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
</TwoColumn>
</template>
-217
View File
@@ -1,217 +0,0 @@
<template>
<div class="network-graph fill-height">
<v-card
v-if="selected !== undefined"
class="mt-3 ml-3 px-0"
style="position: absolute; width: 33%; left: 60px; top: 60px; z-index: 5">
<v-card-title>
{{ selected.name }}
<v-spacer></v-spacer>
<v-btn
text
:to="{ name: 'Graph', params: { col: col(selected.id), 'id': id(selected.id) } }"
>
<v-icon>mdi-bullseye</v-icon>
</v-btn>
<v-btn
vv-if="selected.id.startsWith('artifacts/')"
text
@click="to(selected.id)"
>
<v-icon>mdi-open-in-new</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="ma-0 pa-0">
<!--TicketSnippet v-if="selected.id.startsWith('tickets/')"></TicketSnippet-->
<!--ArtifactSnippet v-if="selected.id.startsWith('artifacts/')"></ArtifactSnippet-->
<IDSnippet :id="selected.id"></IDSnippet>
</v-card-text>
</v-card>
<v-main class="fill-height overflow-hidden">
<div class="d-flex flex-column fill-height overflow-hidden">
<v-row class="flex-grow-0 pb-4">
<v-col cols="4" class="pl-8">
<v-slider
class="mt-4 mb-4"
v-model="depth"
dense
:label="'Depth ' + depth"
hide-details
max="10"
min="0"
></v-slider>
</v-col>
<v-col cols="4" class="pl-4">
<v-slider
class="mt-4 mb-4"
v-model="force"
dense
label="Node Distance"
hide-details
max="10000"
min="0"
></v-slider>
</v-col>
<v-col cols="2">
<v-switch label="Hide Labels"
v-model="hidelabel" class="mt-6 mb-4" hide-details>
</v-switch>
</v-col>
</v-row>
<v-row class="flex-grow-1 mt-0">
<d3-network
v-if="g !== undefined && g.links !== undefined"
:net-nodes="nodes"
:net-links="g.links"
:options="options"
@node-click="nodeclick"
class="pt-0 mt-n4"
style="width: 100%; height: 100%"
/>
</v-row>
</div>
</v-main>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import D3Network from "vue-d3-network";
import {API} from "../services/api";
import {Graph, Node} from "../client";
// import TicketSnippet from "../components/snippets/TicketSnippet.vue";
// import ArtifactSnippet from "../components/snippets/ArtifactSnippet.vue";
import IDSnippet from "../components/snippets/IDSnippet.vue";
interface State {
g?: Graph;
force: number;
depth: number;
hidelabel: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
selected?: any;
}
export default Vue.extend({
name: "Graph",
components: {
IDSnippet,
D3Network
},
data: (): State => ({
g: undefined,
force: 3000,
depth: 2,
hidelabel: false,
selected: undefined,
}),
computed: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
options: function (): any {
return {
canvas: false,
nodeLabels: !this.hidelabel,
nodeSize: 20,
linkWidth: 2,
fontSize: 16,
force: this.force
}
},
nodes: function (): Array<any> {
if (this.g === undefined || this.g.nodes === undefined) {
return []
}
return this.lodash.map(this.g.nodes, (node: Node) => {
if (node.id === this.$route.params.col + "/" + this.$route.params.id) {
return {id: node.id, name: node.name, _size: 40, _cssClass: "center", _labelClass: "center"}
}
if (node.id.startsWith("tickets/")) {
return {id: node.id, name: node.name, _size: 30, _cssClass: "ticket", _labelClass: "event"}
}
return {id: node.id, name: node.name}
})
}
},
watch: {
depth: function () {
this.fetchGraph();
},
$route: function () {
this.fetchGraph();
},
},
methods: {
col: function (id: string): string {
let parts = id.split("/");
return parts[0];
},
id: function (id: string): string {
let parts = id.split("/");
return parts[1];
},
to: function (fid: string) {
let col = this.col(fid);
let id = this.id(fid);
if (col === 'tickets') {
this.$router.push({
name: "Ticket",
params: { id: id, type: "-" }
});
return
}
this.$router.push({
name: "Artifact",
params: { artifact: id }
});
},
fetchGraph: function(): void {
API.graph(this.$route.params.col, this.$route.params.id, this.depth).then((response) => {
this.g = response.data;
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
nodeclick(e: any, node: any) {
this.selected = node;
}
},
mounted() {
this.fetchGraph();
},
});
</script>
<style>
.network-graph .node,
.network-graph .node.selected {
stroke: #388E3C !important;
}
.network-graph .node.event {
stroke: #D32F2F !important;
}
.network-graph .node.center {
stroke: #FFEB3B !important;
fill: #FFEB3B !important;
}
.theme--dark .network-graph .node-label,
.theme--dark .network-graph .node-label.event {
fill: #ffffff !important;
}
.network-graph .node-label,
.network-graph .node-label.event {
fill: #000000 !important;
}
.network-graph .link {
stroke: #424242 !important;
}
.network-graph .link.selected,
.network-graph .link:hover,.node:hover{
stroke: #FFEB3B !important;
}
</style>
-13
View File
@@ -1,13 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
name: "Group"
}
</script>
<style scoped>
</style>
-62
View File
@@ -1,62 +0,0 @@
<template>
<v-main style="min-height: 100vh;">
<List
:items="groups"
routername="Group"
itemid="id"
itemname="name"
singular="Group"
plural="Groups"
></List>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {Group} from "../client";
import {API} from "../services/api";
import List from "../components/List.vue";
interface State {
groups: Array<Group>;
}
export default Vue.extend({
name: "GroupList",
components: {List},
data: (): State => ({
groups: [],
}),
methods: {
loadGroups() {
API.listGroups().then((response) => {
this.groups = response.data;
});
},
deleteGroup(name: string) {
// API.deleteU(name).then(() => {
// this.loadAutomations();
// });
},
},
mounted() {
this.loadGroups();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "groups/")) {
reload = true;
}
});
if (reload) {
this.loadGroups()
}
})
},
});
</script>
-219
View File
@@ -1,219 +0,0 @@
<template>
<v-main>
<v-row>
<v-col v-if="statistics" cols="12" lg="7">
<v-row>
<v-col cols="4">
<v-subheader>Unassigned tickets</v-subheader>
<span style="font-size: 60pt; text-align: center; display: block">
<router-link :to="{
name: 'TicketList',
params: { query: 'status == \'open\' AND !owner' }
}">
{{ statistics.unassigned }}
</router-link>
</span>
<v-subheader>Your tickets</v-subheader>
<span style="font-size: 60pt; text-align: center; display: block">
<router-link :to="{
name: 'TicketList',
params: { query: 'status == \'open\' AND owner == \'' + $store.state.user.id + '\'' }
}">
{{ $store.state.user.id in statistics.open_tickets_per_user ? statistics.open_tickets_per_user[$store.state.user.id] : 0 }}
</router-link>
</span>
</v-col>
<v-col cols="8">
<v-subheader>Open tickets per owner</v-subheader>
<bar-chart
v-if="open_tickets_per_user"
:chart-data="open_tickets_per_user"
:styles="{
width: '100%',
'max-height': '400px',
position: 'relative'
}"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
legend: undefined,
scales: { xAxes: [ { ticks: { beginAtZero: true, precision: 0 } } ] },
onClick: clickUser,
hover: {
onHover: function(e) {
var point = this.getElementAtEvent(e);
if (point.length) e.target.style.cursor = 'pointer';
else e.target.style.cursor = 'default';
}
}
}"
></bar-chart>
</v-col>
</v-row>
<v-row>
<v-col cols="7">
<v-subheader>Tickets created per week</v-subheader>
<line-chart
v-if="tickets_per_week"
:chart-data="tickets_per_week"
:styles="{ width: '100%', position: 'relative' }"
:chart-options="{
responsive: true,
maintainAspectRatio: false,
legend: undefined,
scales: { yAxes: [ { ticks: { beginAtZero: true, precision: 0 } } ] }
}"
>
</line-chart>
</v-col>
<v-col cols="5">
<v-subheader>Ticket Types</v-subheader>
<pie-chart
v-if="tickets_per_type"
:chart-data="tickets_per_type"
:styles="{ width: '100%', position: 'relative' }"
:chart-options="{
onClick: clickPie,
hover: {
onHover: function(e) {
var point = this.getElementAtEvent(e);
if (point.length) e.target.style.cursor = 'pointer';
else e.target.style.cursor = 'default';
}
}
}"
>
</pie-chart>
</v-col>
</v-row>
</v-col>
<v-col cols="12" lg="5">
<TicketList :type="this.$route.params.type" @click="open"></TicketList>
</v-col>
</v-row>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import LineChart from "../components/charts/Line";
import BarChart from "../components/charts/Bar";
import PieChart from "../components/charts/Doughnut";
import { API } from "@/services/api";
import {Statistics, TicketResponse} from "@/client";
import {DateTime} from "luxon";
import { colors } from "@/plugins/vuetify";
import TicketList from "@/components/TicketList.vue";
import { createHash } from "crypto";
export default Vue.extend({
name: "Home",
components: {
LineChart,
BarChart,
PieChart,
TicketList
},
data() {
return {
statistics: (undefined as unknown) as Statistics
};
},
computed: {
tickets_per_type: function () {
let data = { labels: [] as Array<string>, datasets: [{ backgroundColor: [] as Array<string>, data: [] as Array<number> }] }
this.lodash.forEach(this.statistics.tickets_per_type, (count, type) => {
data.labels.push(type);
data.datasets[0].data.push(count);
data.datasets[0].backgroundColor.push(this.color(type));
})
return data
},
open_tickets_per_user: function () {
let data = { labels: [] as Array<string>, datasets: [{ backgroundColor: [] as Array<string>, data: [] as Array<number> }] }
this.lodash.forEach(this.statistics.open_tickets_per_user, (count, user) => {
if (!user) {
data.labels.push("unassigned");
} else {
data.labels.push(user);
}
data.datasets[0].data.push(count);
data.datasets[0].backgroundColor.push(this.color(user));
})
return data
},
tickets_per_week: function () {
let data = {labels: [] as Array<string>, datasets: [{backgroundColor: [] as Array<string>, data: [] as Array<number> }]}
this.lodash.forEach(this.weeks(), (week) => {
data.labels.push(week);
if (week in this.statistics.tickets_per_week) {
data.datasets[0].data.push(this.statistics.tickets_per_week[week]);
} else {
data.datasets[0].data.push(0);
}
data.datasets[0].backgroundColor.push("#607d8b");
})
return data
}
},
methods: {
open: function (ticket: TicketResponse) {
if (ticket.id === undefined) {
return;
}
this.$router.push({
name: "Ticket",
params: {type: '-', id: ticket.id.toString()}
});
},
clickUser: function (evt, elem) {
let owner = this.open_tickets_per_user.labels[elem[0]._index];
let query = 'status == \'open\' AND owner == \'' + owner + '\'';
if (owner == 'unassigned') {
query = 'status == \'open\' AND !owner';
}
this.$router.push({
name: "TicketList",
params: {query: query}
});
},
clickPie: function (evt, elem) {
this.$router.push({
name: "TicketList",
params: {type: this.tickets_per_type.labels[elem[0]._index]}
});
},
color: function (s: string): string {
let pos = createHash('md5').update(s).digest().readUInt32BE(0) % colors.length;
return colors[pos];
},
fillData() {
API.getStatistics().then(response => {
this.statistics = response.data;
});
},
weeks: function () {
let w = [] as Array<string>;
for (let i = 0; i < 53; i++) {
w.push(DateTime.utc().minus({ weeks: i }).toFormat("kkkk-WW"))
}
this.lodash.reverse(w);
return w
}
},
mounted() {
this.fillData();
}
});
</script>
<style>
canvas {
position: relative !important;
margin: 0 auto;
}
</style>
-186
View File
@@ -1,186 +0,0 @@
<template>
<div>
<div v-if="$route.params.id == 'new'" class="fill-height d-flex flex-column pa-8">
<v-alert v-if="readonly" type="info">
You do not have write access to jobs.
</v-alert>
<h2>Start new job</h2>
<v-select
label="Automations"
:items="globalautomations"
item-text="id"
return-object
v-model="automation"
></v-select>
<v-form v-if="automation" v-model="valid">
<v-jsf
v-model="data"
:schema="JSON.parse(automation.schema)"
:options="{ readonly: true, formats: { time: timeformat, date: dateformat, 'date-time': datetimeformat } }"
/>
<v-btn @click="run" color="success" outlined :disabled="!valid">
Run
</v-btn>
</v-form>
</div>
<div v-else-if="job !== undefined">
<h2>Job: {{ job.id }}</h2>
<div v-if="job.automation" class="flex-grow-0 flex-shrink-0">
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">Automation</v-subheader>
{{ job.automation }}
</div>
<div v-if="job.container" class="flex-grow-0 flex-shrink-0">
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">Container</v-subheader>
{{ job.container }}
</div>
<div v-if="job.status" class="flex-grow-0 flex-shrink-0">
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">Status</v-subheader>
{{ job.status }}
</div>
<div v-if="job.payload" class="flex-grow-0 flex-shrink-0">
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">Input Payload</v-subheader>
<Editor :value="JSON.stringify(job.payload, null, 2)" lang="json" :readonly="true"></Editor>
</div>
<div v-if="job.log" class="flex-grow-0 flex-shrink-0">
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">Log</v-subheader>
<Editor :value="job.log" lang="log" :readonly="true"></Editor>
</div>
<div v-if="job.output" class="flex-grow-0 flex-shrink-0">
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">Output</v-subheader>
<Editor :value="JSON.stringify(job.output, null, 2)" lang="json" :readonly="true"></Editor>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import {AutomationResponse, AutomationResponseTypeEnum, JobResponse} from "@/client";
import {API} from "@/services/api";
import {DateTime} from "luxon";
import Editor from "@/components/Editor.vue";
interface State {
job?: JobResponse,
data?: any;
valid: boolean;
automation?: AutomationResponse;
automations: Array<AutomationResponse>;
}
export default Vue.extend({
name: "Job",
components: { Editor },
data: (): State => ({
job: undefined,
data: undefined,
valid: true,
automation: undefined,
automations: [],
}),
watch: {
$route: 'loadJob',
},
computed: {
readonly: function (): boolean {
return !this.hasRole("job:write");
},
globalautomations: function (): Array<AutomationResponse> {
if (!this.automations) {
return [];
}
return this.lodash.filter(this.automations, (automation: AutomationResponse) => {
if (!automation || !automation.type) {
return true;
}
return this.lodash.includes(automation.type, AutomationResponseTypeEnum.Global)
})
},
},
methods: {
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);
},
run: function () {
if (!this.automation) {
return;
}
API.runJob({ automation: this.automation.id, payload: this.data }).then(() => {
this.$store.dispatch("alertSuccess", { name: "Job started." });
})
},
loadJob() {
if (!this.$route.params.id) {
return
}
if (this.$route.params.id == 'new') {
// this.template = { name: "MyTemplate", schema: '{ "type": "object", "name": "Incident" }' }
// this.schema = { type: "object", name: "Incident" }
} else {
API.getJob(this.$route.params.id).then((response) => {
this.job = response.data;
});
}
},
loadAutomations() {
API.listAutomations().then((response) => {
this.automations = response.data;
});
},
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.loadJob();
this.loadAutomations();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "jobs/")) {
reload = true;
}
});
if (reload) {
this.loadJob()
}
})
},
});
</script>
-108
View File
@@ -1,108 +0,0 @@
<template>
<v-main style="min-height: 100vh;">
<v-row class="fill-height ma-0">
<v-col cols="3" class="listnav" style="">
<v-list nav color="background">
<v-list-item
v-if="canWrite"
:to="{ name: 'Job', 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 Job
</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-subheader class="pl-4">Jobs</v-subheader>
<v-list-item
v-for="item in (jobs ? jobs : [])"
:key="item.id"
link
:to="{ name: 'Job', params: { id: item.id } }"
class="mx-2"
>
<v-list-item-icon>
<v-icon v-if="item.status === 'created'">mdi-star</v-icon>
<v-icon v-else-if="item.status === 'running'">mdi-run-fast</v-icon>
<v-icon v-else-if="item.status === 'paused'">mdi-pause</v-icon>
<v-icon v-else-if="item.status === 'restarting'">mdi-restart</v-icon>
<v-icon v-else-if="item.status === 'removing'">mdi-close</v-icon>
<v-icon v-else-if="item.status === 'exited'">mdi-exit-to-app</v-icon>
<v-icon v-else-if="item.status === 'dead'">mdi-skull</v-icon>
<v-icon v-else-if="item.status === 'completed'">mdi-check</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-subtitle>
{{ item.id }}
</v-list-item-subtitle>
<v-list-item-title>
{{ item.automation }} ({{ item.status }})
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-col>
<v-col cols="9">
<router-view></router-view>
</v-col>
</v-row>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {JobResponse} from "@/client";
import {API} from "@/services/api";
interface State {
jobs: Array<JobResponse>;
}
export default Vue.extend({
name: "JobList",
components: {},
data: (): State => ({
jobs: [],
}),
computed: {
canWrite: function (): boolean {
return this.hasRole("job:write");
},
},
methods: {
loadJobs() {
API.listJobs().then((response) => {
if (response.data) {
this.jobs = response.data;
}
});
},
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.loadJobs();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "jobs/")) {
reload = true;
}
});
if (reload) {
this.loadJobs()
}
})
},
});
</script>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { useQuery } from '@tanstack/vue-query'
import { ref, watch } from 'vue'
import { pb } from '@/lib/pocketbase'
const mail = ref('')
const password = ref('')
const errorMessage = ref('')
const login = () => {
pb.collection('users')
.authWithPassword(mail.value, password.value)
.then(() => {
window.location.href = '/ui/'
})
.catch((error) => {
errorMessage.value = error.message
})
}
const { data: config } = useQuery({
queryKey: ['config'],
queryFn: (): Promise<Record<string, Array<String>>> => pb.send('/api/config', {})
})
watch(config, (newConfig) => {
if (!newConfig) return
if (newConfig['flags'].includes('demo') || newConfig['flags'].includes('dev')) {
mail.value = 'user@catalyst-soar.com'
password.value = '1234567890'
}
})
</script>
<template>
<div class="flex h-full w-full flex-1 items-center justify-center">
<Card class="m-auto w-96">
<CardHeader class="flex flex-row justify-between">
<CardTitle>Catalyst</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<Alert v-if="errorMessage" variant="destructive" class="border-4 p-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription>
</Alert>
<Input
v-model="mail"
type="text"
placeholder="Username"
class="w-full"
@keydown.enter="login"
/>
<Input
v-model="password"
type="password"
placeholder="Password"
class="w-full"
@keydown.enter="login"
/>
<Button variant="outline" class="w-full" @click="login"> Login</Button>
</CardContent>
</Card>
</div>
</template>
-13
View File
@@ -1,13 +0,0 @@
<template>
<v-main>
<div class="fill-height d-flex flex-row align-center justify-center">
<h1>Page not found :(</h1>
</div>
</v-main>
</template>
<script>
export default {
name: "NotFound"
}
</script>
-293
View File
@@ -1,293 +0,0 @@
<template>
<div v-if="playbook !== undefined" class="fill-height d-flex flex-column pa-8">
<v-row class="flex-grow-0 flex-shrink-0">
<v-spacer></v-spacer>
<v-btn href="https://catalyst-soar.com/docs/catalyst/engineer/playbook" target="_blank" outlined rounded small>
<v-icon>mdi-book-open</v-icon> Handbook
</v-btn>
</v-row>
<v-alert v-if="readonly" type="info">You do not have write access to playbooks.</v-alert>
<h2 v-if="this.$route.params.id === 'new'">New Playbook</h2>
<h2 v-else>Edit Playbook: {{ playbook.name }}</h2>
<v-alert v-if="formaterrors.length" color="warning">
<div v-for="(formaterror, index) in formaterrors" :key="index">
{{ formaterror.instancePath }}{{ formaterror.instancePath ? ": " : "" }}{{ formaterror.message }}
</div>
</v-alert>
<v-alert v-else-if="error" color="warning">
{{ error }}
</v-alert>
<div class="flex-grow-1 flex-shrink-1 overflow-scroll">
<v-tabs
v-model="tab"
background-color="transparent"
>
<v-tab>Graph (experimental)</v-tab>
<v-tab>YAML</v-tab>
</v-tabs>
<v-tabs-items v-model="tab" style="background: transparent">
<v-tab-item>
<v-text-field
v-model="playbook.name"
label="Name"
outlined
dense
:readonly="readonly"
class="mt-4"
/>
<PlaybookEditor
v-if="playbookJSON"
v-model="playbookJSON" />
</v-tab-item>
<v-tab-item>
<v-card class="py-2">
<Editor v-model="playbookYAML" lang="yaml" :readonly="readonly"></Editor>
</v-card>
</v-tab-item>
</v-tabs-items>
</div>
<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 Vue from "vue";
import {Playbook, PlaybookTemplate, Task, TaskResponse} from "../client";
import { API } from "@/services/api";
import Editor from "../components/Editor.vue";
import {alg, Graph} from "graphlib";
import yaml from 'yaml';
import Ajv from "ajv";
import PlaybookEditor from "@/components/playbookeditor/PlaybookEditor.vue";
const playbookSchema = {
type: "object",
required: ["name", "tasks"],
properties: {
name: { type: "string" },
tasks: {
type: "object",
additionalProperties: { $ref: "#/definitions/Task" }
}
},
// additionalProperties: false,
$id: "#/definitions/Playbook"
};
const taskSchema = {
type: "object",
required: ["name", "type"],
properties: {
automation: { type: "string" },
join: { type: "boolean" },
payload: { type: "object", additionalProperties: { type: "string" } },
name: { type: "string" },
next: {
type: "object",
additionalProperties: { type: ["string", "null"] }
},
schema: { type: "object" },
type: { type: "string", enum: ["task", "input", "automation"] }
},
// additionalProperties: false,
$id: "#/definitions/Task"
};
interface State {
playbook?: PlaybookTemplate;
g: Record<string, any>;
selected: any;
error: string;
tab: number;
playbookYAML: string;
playbookJSON: any;
}
interface TaskWithID {
id: string;
task: Task;
}
const inityaml = "name: VirusTotal hash check\n" +
"tasks:\n" +
" input:\n" +
" name: Please enter a word\n" +
" type: input\n" +
" schema:\n" +
" title: Word\n" +
" type: object\n" +
" properties:\n" +
" word:\n" +
" type: string\n" +
" title: Enter a Word\n" +
" default: \"\"\n" +
" next:\n" +
" hash: \"word != ''\"\n" +
"\n" +
" hash:\n" +
" name: Hash the word\n" +
" type: automation\n" +
" automation: hash.sha1\n" +
" payload:\n" +
" default: \"playbook.tasks['input'].data['word']\"\n" +
" next:\n" +
" end:\n" +
"\n" +
" end:\n" +
" name: Finish the incident\n" +
" type: task\n"
export default Vue.extend({
name: "Playbook",
components: { Editor, PlaybookEditor },
data: (): State => ({
playbook: undefined,
g: {},
selected: undefined,
error: "",
tab: 1,
playbookJSON: undefined,
playbookYAML: inityaml
}),
watch: {
'$route': function () {
this.loadPlaybook();
},
tab: function (value) {
if (value === 0) {
this.playbookJSON = yaml.parse(this.playbookYAML);
} else {
this.playbookYAML = yaml.stringify(this.playbookJSON);
}
}
},
computed: {
formaterrors: function (): Array<any> {
if (!this.playbook) {
return [];
}
try {
let playbook = yaml.parse(this.playbook.yaml);
const ajv = new Ajv({validateFormats: false});
ajv.addSchema(taskSchema, "#/definitions/Task")
const validate = ajv.compile(playbookSchema);
const valid = validate(playbook)
if (!valid && validate.errors) {
return validate.errors;
}
return [];
}
catch (e) {
return [e];
}
},
readonly: function (): boolean {
return !this.hasRole("playbook:write");
},
},
methods: {
gstatus: function(task: TaskResponse) {
if (task.active) {
return "open"
}
return "inactive"
},
tasks: function(g: any, playbook: Playbook): Array<TaskWithID> {
let taskKeys = alg.topsort(g);
let tasks = [] as Array<TaskWithID>;
for (const tasksKey in taskKeys) {
let taskWithID = {} as TaskWithID;
if (playbook.tasks[taskKeys[tasksKey]] === undefined) {
continue; // TODO
}
taskWithID.task = playbook.tasks[taskKeys[tasksKey]];
taskWithID.id = taskKeys[tasksKey];
tasks.push(taskWithID);
}
return tasks;
},
save() {
if (this.playbook === undefined) {
return;
}
let playbook = this.playbook;
if (this.tab === 0) {
let jsonData = this.playbookJSON;
jsonData["name"] = playbook.name;
playbook.yaml = yaml.stringify(jsonData);
} else {
playbook.yaml = this.playbookYAML;
}
if (this.$route.params.id == 'new') {
// playbook.id = kebabCase(playbook.name);
API.createPlaybook(playbook).then(() => {
this.$store.dispatch("alertSuccess", { name: "Playbook created" });
});
} else {
API.updatePlaybook(this.$route.params.id, playbook).then(() => {
this.$store.dispatch("alertSuccess", { name: "Playbook saved" });
});
}
},
loadPlaybook() {
if (!this.$route.params.id) {
return
}
if (this.$route.params.id == 'new') {
this.playbook = { name: "MyPlaybook", yaml: inityaml }
this.playbookJSON = yaml.parse(this.playbook.yaml);
this.playbookYAML = this.playbook.yaml;
} else {
API.getPlaybook(this.$route.params.id).then((response) => {
this.playbook = response.data;
this.playbookJSON = yaml.parse(this.playbook.yaml);
this.playbookYAML = this.playbook.yaml;
});
}
},
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.loadPlaybook();
},
});
</script>
<style>
.my-code {
background: #2d2d2d;
color: #ccc;
width: inherit;
height: inherit;
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
font-size: 14px;
line-height: 1.5;
padding: 15px;
}
.pipeline-node-label {
fill: #333 !important;
}
</style>
-66
View File
@@ -1,66 +0,0 @@
<template>
<v-main style="min-height: 100vh;">
<List
:items="playbooks"
routername="Playbook"
itemid="id"
itemname="name"
singular="Playbook"
plural="Playbooks"
@delete="deletePlaybook"
writepermission="playbook:write"
></List>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {PlaybookTemplate} from "../client";
import {API} from "../services/api";
import List from "../components/List.vue";
interface State {
playbooks: Array<PlaybookTemplate>;
}
export default Vue.extend({
name: "PlaybookList",
components: {List},
data: (): State => ({
playbooks: [],
}),
methods: {
loadPlaybooks() {
API.listPlaybooks().then((response) => {
if (response.data) {
this.playbooks = response.data;
}
});
},
deletePlaybook(name: string) {
API.deletePlaybook(name).then(() => {
this.loadPlaybooks();
});
},
},
mounted() {
this.loadPlaybooks();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "playbooks/")) {
reload = true;
}
});
if (reload) {
this.loadPlaybooks()
}
})
},
});
</script>
-53
View File
@@ -1,53 +0,0 @@
<template>
<v-main v-if="userdata">
<user-data-editor :userdata="userdata" @save="saveUserData"></user-data-editor>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import { UserData } from "@/client";
import { API } from "@/services/api";
import UserDataEditor from "@/components/UserDataEditor.vue";
import axios, {AxiosResponseTransformer} from "axios";
interface State {
userdata?: UserData;
}
export default Vue.extend({
name: "Profile",
data: (): State => ({
userdata: undefined,
}),
components: {
UserDataEditor,
},
watch: {
$route: function () {
this.loadUserData();
},
},
methods: {
saveUserData: function(userdata: UserData) {
API.updateCurrentUserData(userdata).then(() => {
this.$store.dispatch("alertSuccess", { name: "User data saved" });
});
},
loadUserData: function () {
const defaultTransformers = axios.defaults.transformResponse as AxiosResponseTransformer[]
const transformResponse = defaultTransformers.concat((data) => {
data.notoast = true;
return data
});
API.currentUserData({transformResponse: transformResponse}).then((response) => {
this.userdata = response.data;
});
}
},
mounted() {
this.loadUserData();
}
});
</script>
-89
View File
@@ -1,89 +0,0 @@
<template>
<div v-if="rule !== undefined" class="flex-grow-1 flex-column d-flex fill-height pa-8">
<h2 v-if="this.$route.params.id === 'new'">New Rule: {{ rule.name }}</h2>
<h2 v-else>Edit Rule: {{ rule.name }}</h2>
<v-text-field label="Name" v-model="rule.name" class="flex-grow-0 flex-shrink-0"></v-text-field>
<v-text-field label="Condition" v-model="rule.condition" class="flex-grow-0 flex-shrink-0"></v-text-field>
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">
Update
</v-subheader>
<div class="flex-grow-1 flex-shrink-1 overflow-scroll">
<Editor v-model="update" lang="json"></Editor>
</div>
<v-row 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" outlined @click="save">
<v-icon>mdi-content-save</v-icon>
Save
</v-btn>
</v-row>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { Rule } from "@/client";
import { API } from "@/services/api";
import Editor from "../components/Editor.vue";
interface State {
rule?: Rule;
update: string;
}
export default Vue.extend({
name: "Rule",
components: { Editor },
data: (): State => ({
rule: undefined,
update: "",
}),
watch: {
$route: function () {
this.loadRule();
},
},
methods: {
save() {
if (this.rule === undefined) {
return;
}
let rule = this.rule;
// rule.id = kebabCase(rule.name);
rule.update = JSON.parse(this.update);
if (this.$route.params.id == 'new') {
API.createRule(rule).then(() => {
this.$store.dispatch("alertSuccess", { name: "Rule created" });
});
} else {
API.updateRule(this.$route.params.id, rule).then(() => {
this.$store.dispatch("alertSuccess", { name: "Rule saved" });
});
}
},
loadRule() {
if (!this.$route.params.id) {
return
}
if (this.$route.params.id == 'new') {
this.rule = { name: "MyPlaybook", condition: "type == alert", update: { details: { status: "ignored" } } }
} else {
API.getRule(this.$route.params.id).then((response) => {
this.rule = response.data;
this.update = JSON.stringify(response.data.update, null, 4)
});
}
},
},
mounted() {
this.loadRule();
},
});
</script>
-63
View File
@@ -1,63 +0,0 @@
<template>
<v-main style="min-height: 100vh;">
<List
:items="rules"
routername="Rule"
itemid="id"
itemname="name"
singular="Rule"
plural="Rules"
@delete="deleteRule"
></List>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {Rule} from "@/client";
import {API} from "@/services/api";
import List from "../components/List.vue";
interface State {
rules: Array<Rule>;
}
export default Vue.extend({
name: "RuleList",
components: {List},
data: (): State => ({
rules: [],
}),
methods: {
loadRules() {
API.listRules().then((response) => {
this.rules = response.data;
});
},
deleteRule(name: string) {
API.deleteRule(name).then(() => {
this.loadRules();
});
},
},
mounted() {
this.loadRules();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "rules/")) {
reload = true;
}
});
if (reload) {
this.loadRules()
}
})
},
});
</script>
-177
View File
@@ -1,177 +0,0 @@
<template>
<v-main class="ma-4">
<div v-if="settings !== undefined">
<v-text-field label="Time Format" v-model="settings.timeformat"></v-text-field>
<v-subheader class="mx-0 px-0">Artifact States</v-subheader>
<v-card v-for="state in settings.artifactStates" :key="state.id" class="d-flex mb-2">
<v-row class="px-4 pt-2" dense>
<v-col>
<v-text-field label="ID" v-model="state.id"></v-text-field>
</v-col>
<v-col>
<v-text-field label="Name" v-model="state.name"></v-text-field>
</v-col>
<v-col>
<v-text-field label="Icon" v-model="state.icon"></v-text-field>
</v-col>
<v-col>
<v-select label="Color" v-model="state.color" :items="['info', 'error', 'success', 'warning']" clearable></v-select>
</v-col>
</v-row>
<v-btn icon class="mt-6 mr-4" @click="removeState(state.id)"><v-icon>mdi-close</v-icon></v-btn>
</v-card>
<v-card class="d-flex mb-2">
<v-row class="px-4 pt-2" dense>
<v-col>
<v-text-field label="ID" v-model="newState.id"></v-text-field>
</v-col>
<v-col>
<v-text-field label="Name" v-model="newState.name"></v-text-field>
</v-col>
<v-col>
<v-text-field label="Icon" v-model="newState.icon"></v-text-field>
</v-col>
<v-col>
<v-select label="Color" v-model="newState.color" :items="['info', 'error', 'success', 'warning']" clearable></v-select>
</v-col>
</v-row>
<v-btn icon class="mt-6 mr-4" @click="addState"><v-icon>mdi-plus</v-icon></v-btn>
</v-card>
<v-subheader class="mx-0 px-0">Artifact Types</v-subheader>
<v-card v-for="state in settings.artifactKinds" :key="state.id" class="d-flex mb-2">
<v-row class="px-4 pt-2" dense>
<v-col>
<v-text-field label="ID" v-model="state.id"></v-text-field>
</v-col>
<v-col>
<v-text-field label="Name" v-model="state.name"></v-text-field>
</v-col>
<v-col>
<v-text-field label="Icon" v-model="state.icon"></v-text-field>
</v-col>
<v-col>
<v-select label="Color" v-model="state.color" :items="['info', 'error', 'success', 'warning']" clearable></v-select>
</v-col>
</v-row>
<v-btn icon class="mt-6 mr-4" @click="removeKind(state.id)"><v-icon>mdi-close</v-icon></v-btn>
</v-card>
<v-card class="d-flex mb-2">
<v-row class="px-4 pt-2" dense>
<v-col>
<v-text-field label="ID" v-model="newKind.id"></v-text-field>
</v-col>
<v-col>
<v-text-field label="Name" v-model="newKind.name"></v-text-field>
</v-col>
<v-col>
<v-text-field label="Icon" v-model="newKind.icon"></v-text-field>
</v-col>
<v-col>
<v-select label="Color" v-model="newKind.color" :items="['info', 'error', 'success', 'warning']" clearable></v-select>
</v-col>
</v-row>
<v-btn icon class="mt-6 mr-4" @click="addKind"><v-icon>mdi-plus</v-icon></v-btn>
</v-card>
<v-btn color="success" @click="save" outlined class="mt-2">
<v-icon>mdi-content-save</v-icon>
Save
</v-btn>
</div>
</v-main>
</template>
<script lang="ts">
import {DateTime} from "luxon";
import Vue from "vue";
import {Settings, SettingsResponse, Type} from "@/client";
import {API} from "@/services/api";
import {AxiosResponse} from "axios";
interface State {
valid: boolean;
settings?: Settings;
newState: Type;
newKind: Type;
}
export default Vue.extend({
name: "Settings",
data: (): State => ({
valid: true,
settings: undefined,
newState: {} as Type,
newKind: {} as Type,
}),
methods: {
save: function () {
if (this.settings === undefined) {
return
}
API.saveSettings(this.settings).then((response) => {
this.settings = response.data;
this.$store.dispatch("getSettings");
})
},
addState: function () {
if (this.settings === undefined) {
return
}
this.settings.artifactStates.push(this.newState);
this.newState = {} as Type;
},
removeState: function (id: string) {
if (this.settings === undefined) {
return
}
this.settings.artifactStates = this.lodash.filter(this.settings.artifactStates, function (t) { return t.id !== id });
},
addKind: function () {
if (this.settings === undefined) {
return
}
this.settings.artifactKinds.push(this.newKind);
this.newKind = {} as Type;
},
removeKind: function (id: string) {
if (this.settings === undefined) {
return
}
this.settings.artifactKinds = this.lodash.filter(this.settings.artifactKinds, function (t) { return t.id !== id });
},
timeformat: function (s: 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) {
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) {
let format = this.$store.state.settings.timeformat;
if (!format) {
return DateTime.fromISO(s).toLocaleString(DateTime.DATETIME_SHORT);
}
return DateTime.fromISO(s).toFormat(format);
},
},
mounted() {
API.getSettings().then((response: AxiosResponse<SettingsResponse>) => {
this.settings = response.data;
})
}
});
</script>
-85
View File
@@ -1,85 +0,0 @@
<template>
<v-main>
<v-container>
<v-data-table
:headers="headers"
:items="tasks ? tasks : []"
item-key="name"
multi-sort
class="elevation-1 cards clickable"
:loading="loading"
:footer-props="{ 'items-per-page-options': [10, 25, 50, 100] }"
@click:row="open"
>
</v-data-table>
</v-container>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {TaskWithContext} from "@/client";
import {API} from "@/services/api";
interface State {
tasks: Array<TaskWithContext>;
loading: boolean;
}
export default Vue.extend({
name: "TaskList",
data: (): State => ({
tasks: [],
loading: true,
}),
watch: {
$route: function () {
this.loadTasks();
},
},
computed: {
headers() {
return [
{
text: "Ticket",
align: "start",
value: "ticket_name"
},
{
text: "Playbook",
align: "start",
value: "playbook_name"
},
{
text: "Task",
align: "start",
value: "task.name"
},
{
text: "Owner",
align: "start",
value: "task.owner"
}
];
}
},
methods: {
open(task: TaskWithContext) {
this.$router.push({
name: "Ticket",
params: { id: task.ticket_id.toString(), type: "-" }
});
},
loadTasks() {
this.loading = true;
API.listTasks().then((reponse) => {
this.tasks = reponse.data;
this.loading = false;
})
}
},
mounted() {
this.loadTasks();
}
});
</script>
-94
View File
@@ -1,94 +0,0 @@
<template>
<div v-if="template !== undefined" class="flex-grow-1 flex-column d-flex fill-height pa-8">
<v-row class="flex-grow-0 flex-shrink-0">
<v-spacer></v-spacer>
<v-btn href="https://catalyst-soar.com/docs/catalyst/engineer/template" target="_blank" outlined rounded small>
<v-icon>mdi-book-open</v-icon> Handbook
</v-btn>
</v-row>
<v-alert v-if="readonly" type="info">
You do not have write access to templates.
Changes here cannot be saved.
</v-alert>
<div class="d-flex" style="align-items: center">
<h2 v-if="readonly">Template: {{ template.name }}</h2>
<h2 v-else-if="this.$route.params.id === 'new'">New Template: {{ template.name }}</h2>
<h2 v-else>Edit Template: {{ template.name }}</h2>
</div>
<v-text-field id="name-edit" label="Name" v-model="template.name" class="flex-grow-0 flex-shrink-0" :readonly="readonly"></v-text-field>
<AdvancedJSONSchemaEditor id="template-edit" v-if="schema" @save="save" :schema="schema" :readonly="readonly" :hidepreview="false"></AdvancedJSONSchemaEditor>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { TicketTemplate } from "@/client";
import AdvancedJSONSchemaEditor from "../components/AdvancedJSONSchemaEditor.vue";
interface State {
template?: TicketTemplate,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema?: any;
}
export default Vue.extend({
name: "Template",
components: { AdvancedJSONSchemaEditor },
data: (): State => ({
template: undefined,
schema: undefined,
}),
watch: {
$route: 'loadTemplate',
},
computed: {
readonly: function (): boolean {
return !this.hasRole("template:write");
},
},
methods: {
save(schema) {
if (this.template === undefined) {
return;
}
let template = this.template;
template.schema = schema;
if (this.$route.params.id == 'new') {
this.$store.dispatch("addTemplate", template).then(() => {
this.$router.push({name: "TemplateList" });
});
} else {
this.$store.dispatch("updateTemplate", { id: this.$route.params.id, template: template });
}
},
loadTemplate() {
if (!this.$route.params.id) {
return
}
if (this.$route.params.id == 'new') {
this.template = { name: "MyTemplate", schema: '{ "type": "object", "name": "Incident" }' }
this.schema = { type: "object", name: "Incident" }
} else {
this.$store.dispatch("getTemplate", this.$route.params.id).then((response: TicketTemplate) => {
this.template = response;
this.schema = JSON.parse(response.schema);
});
}
},
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.loadTemplate();
},
});
</script>
-50
View File
@@ -1,50 +0,0 @@
<template>
<v-main style="min-height: 100vh;">
<List
:items="$store.state.templates.templates"
routername="Template"
itemid="id"
itemname="name"
singular="Template"
plural="Templates"
writepermission="template:write"
@delete="deleteTemplate"
></List>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import List from "../components/List.vue";
export default Vue.extend({
name: "TemplateList",
components: {List},
methods: {
deleteTemplate(title: string) {
this.$store.dispatch("deleteTemplate", title).then(() => {
this.$router.push({name: "TemplateList"});
});
}
},
mounted() {
this.$store.dispatch("listTemplates");
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "templates/")) {
reload = true;
}
});
if (reload) {
this.$store.dispatch("listTemplates");
}
})
}
});
</script>
File diff suppressed because it is too large Load Diff
-77
View File
@@ -1,77 +0,0 @@
<template>
<v-main style="min-height: 100vh">
<splitpanes class="default-theme" @resize="paneSize = $event[0].size">
<pane class="pa-3" :size="paneSize">
<TicketListComponent :type="$route.params.type" :query="query" @click="open" @new="opennew"></TicketListComponent>
</pane>
<pane v-if="this.$route.params.id" class="pa-3" :size="100 - paneSize">
<v-row>
<v-spacer></v-spacer>
<v-btn @click="close" outlined rounded class="mt-2 mr-2" small>
<v-icon>mdi-close</v-icon>
Close
</v-btn>
</v-row>
<router-view></router-view>
</pane>
</splitpanes>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {TicketResponse} from "@/client";
import { Splitpanes, Pane } from 'splitpanes';
import 'splitpanes/dist/splitpanes.css';
import TicketListComponent from "../components/TicketList.vue";
export default Vue.extend({
name: "TicketList",
components: {
TicketListComponent,
Splitpanes, Pane,
},
props: ['query', 'type'],
data: () => ({
paneSize: 30,
}),
methods: {
numeric: function (n: any): boolean {
return !isNaN(parseFloat(n)) && !isNaN(n - 0);
},
open: function (ticket: TicketResponse) {
this.$router.push({
name: "Ticket",
params: {type: this.$route.params.type, id: ticket.id.toString()}
}).then(() => {
this.paneSize = 30;
});
},
opennew: function () {
this.paneSize = 30;
},
close: function () {
this.$router.push({
name: "TicketList",
params: {type: this.$route.params.type},
}).then(() => {
this.paneSize = 100;
});
},
hidelist: function () {
this.paneSize = 0;
}
}
});
</script>
<style lang="scss">
.splitpanes.default-theme .splitpanes__pane {
background: none;
}
.splitpanes.default-theme .splitpanes__splitter {
background: none;
border: none;
}
</style>
-168
View File
@@ -1,168 +0,0 @@
<template>
<div class="mt-8">
<h2>New {{ $route.params.type | capitalize }}</h2>
<v-form class="create clearfix">
<v-text-field id="title-edit" label="Title" v-model="name"></v-text-field>
<div id="playbooks-edit">
<v-select
label="Playbooks"
:items="playbooks"
item-text="name"
return-object
multiple
v-model="selectedPlaybooks"
></v-select>
</div>
<div id="templates-edit">
<v-select
label="Template"
:items="templates"
item-text="name"
return-object
v-model="selectedTemplate"
></v-select>
</div>
<v-subheader class="pl-0 mt-4" style="height: 20px">Details</v-subheader>
<div v-if="selectedTemplate !== undefined" class="details">
<v-lazy>
<v-jsf
v-model="details"
:options="{ fieldProps: { 'hide-details': true } }"
:schema="selectedSchema"
/>
</v-lazy>
</div>
<v-btn color="green" @click="createTicket" outlined>
Create
</v-btn>
</v-form>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import {
PlaybookTemplateForm,
PlaybookTemplateResponse,
TicketForm,
TicketTemplateResponse,
TicketType
} from "@/client";
import { API } from "@/services/api";
interface State {
selectedTemplate?: TicketTemplateResponse;
templates: Array<TicketTemplateResponse>;
selectedPlaybooks: Array<PlaybookTemplateResponse>;
playbooks: Array<PlaybookTemplateResponse>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
details: any;
name: string;
ticketType?: TicketType;
}
export default Vue.extend({
name: "TicketNew",
// components: { VJsf },
data: (): State => ({
selectedTemplate: undefined,
templates: [],
selectedPlaybooks: [],
playbooks: [],
name: "",
details: {},
ticketType: undefined,
}),
computed: {
selectedSchema() {
if (
this.selectedTemplate === undefined ||
this.selectedTemplate.schema === undefined
) {
return {};
}
return JSON.parse(this.selectedTemplate.schema);
}
},
methods: {
createTicket: function() {
if (
this.selectedTemplate === undefined ||
this.selectedTemplate?.schema === undefined
) {
this.$store.dispatch("alertError", { name: "No template selected" });
return;
}
let ticket: TicketForm = {
type: this.$route.params.type,
name: this.name,
details: this.details,
schema: this.selectedTemplate?.schema,
status: "open",
owner: this.$store.state.user.id,
playbooks: this.selectedPlaybooks as Array<PlaybookTemplateForm>,
};
API.createTicket(ticket).then(response => {
if (response.data.id === undefined) {
return;
}
this.$router.push({
name: "Ticket",
params: {
id: response.data.id.toString(),
type: response.data.type,
}
});
});
}
},
mounted() {
API.getTicketType(this.$route.params.type).then((response) => {
this.ticketType = response.data;
API.listTemplates().then(response => {
this.templates = response.data;
this.lodash.forEach(this.templates, (template) => {
if (this.ticketType && template.id == this.ticketType.default_template) {
this.selectedTemplate = template;
}
});
});
API.listPlaybooks().then(response => {
this.playbooks = response.data;
this.lodash.forEach(this.playbooks, (playbook) => {
if (this.ticketType && this.lodash.includes(this.ticketType.default_playbooks, playbook.id)) {
this.selectedPlaybooks.push(playbook);
}
});
});
});
},
});
</script>
<style>
.details > .col > .row > .row,
.details > .col > .row > div > .row {
margin-top: 16px !important;
padding: 16px !important;
background-color: rgba(0, 0, 0, 0.2);
}
.v-application .create .vjsf-property {
margin-bottom: 16px;
}
.v-application .create .error--text {
color: white !important;
}
</style>
-126
View File
@@ -1,126 +0,0 @@
<template>
<div v-if="tickettype !== undefined" class="flex-grow-1 flex-column d-flex fill-height pa-8">
<h2 v-if="this.$route.params.id === 'new'">New Ticket Type: {{ tickettype.name }}</h2>
<h2 v-else>Edit Ticket Type: {{ tickettype.name }}</h2>
<v-text-field label="Name" v-model="tickettype.name" class="flex-grow-0 flex-shrink-0"></v-text-field>
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">
Icon (see <a href="https://materialdesignicons.com" class="mx-1"> materialdesignicons.com </a> and prefix with "mdi-")
</v-subheader>
<v-text-field v-model="tickettype.icon" placeholder="e.g. mdi-alert" class="flex-grow-0 flex-shrink-0 mt-n3"></v-text-field>
<v-select
v-model="selectedTemplate"
:items="templates"
item-text="name"
label="Default Template"
return-object
class="flex-grow-0 flex-shrink-0"
></v-select>
<v-select
v-model="selectedPlaybooks"
:items="playbooks"
item-text="name"
label="Default Playbooks"
return-object
multiple
class="flex-grow-0 flex-shrink-0"
></v-select>
<v-row 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" outlined @click="save">
<v-icon>mdi-content-save</v-icon>
Save
</v-btn>
</v-row>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { PlaybookTemplateResponse, TicketTemplateResponse, TicketType } from "@/client";
import { API } from "@/services/api";
interface State {
tickettype?: TicketType;
selectedPlaybooks: Array<PlaybookTemplateResponse>;
playbooks?: Array<PlaybookTemplateResponse>;
selectedTemplate?: TicketTemplateResponse;
templates?: Array<TicketTemplateResponse>;
}
export default Vue.extend({
name: "TicketType",
data: (): State => ({
tickettype: undefined,
selectedPlaybooks: [],
playbooks: undefined,
selectedTemplate: undefined,
templates: undefined,
}),
watch: {
$route: function () {
this.loadRule();
},
},
methods: {
save() {
if (this.tickettype === undefined) {
return;
}
let tickettype = this.tickettype;
if (this.selectedTemplate) {
tickettype.default_template = this.selectedTemplate.id;
}
tickettype.default_playbooks = [];
this.lodash.forEach(this.selectedPlaybooks, (playbook) => {
tickettype.default_playbooks.push(playbook.id)
})
if (this.$route.params.id == 'new') {
API.createTicketType(tickettype).then(() => {
this.$store.dispatch("alertSuccess", { name: "Ticket type created" });
});
} else {
API.updateTicketType(this.$route.params.id, tickettype).then(() => {
this.$store.dispatch("alertSuccess", { name: "Ticket type saved" });
});
}
},
loadRule() {
if (!this.$route.params.id) {
return
}
API.listTemplates().then((response) => {
this.templates = response.data;
if (response.data.length > 0) {
this.selectedTemplate = response.data[0];
}
})
API.listPlaybooks().then((response) => {
this.playbooks = response.data;
})
if (this.$route.params.id == 'new') {
this.tickettype = { name: "TI Ticket", default_groups: [], default_playbooks: [], default_template: "", icon: "" }
} else {
API.getTicketType(this.$route.params.id).then((response) => {
this.tickettype = response.data;
});
}
},
},
mounted() {
this.loadRule();
},
});
</script>
-64
View File
@@ -1,64 +0,0 @@
<template>
<v-main style="min-height: 100vh;">
<List
:items="tickettypes"
routername="TicketType"
itemid="id"
itemname="name"
singular="Ticket Type"
plural="Ticket Types"
@delete="deleteTicketType"
writepermission="tickettype:write"
></List>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import { TicketType} from "@/client";
import {API} from "@/services/api";
import List from "../components/List.vue";
interface State {
tickettypes: Array<TicketType>;
}
export default Vue.extend({
name: "TicketTypeList",
components: {List},
data: (): State => ({
tickettypes: [],
}),
methods: {
loadTicketType() {
API.listTicketTypes().then((response) => {
this.tickettypes = response.data;
});
},
deleteTicketType(id: string) {
API.deleteTicketType(id).then(() => {
this.loadTicketType();
});
},
},
mounted() {
this.loadTicketType();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "tickettypes/")) {
reload = true;
}
});
if (reload) {
this.loadTicketType()
}
})
},
});
</script>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import ThreeColumn from '@/components/layout/ThreeColumn.vue'
import TicketDisplay from '@/components/ticket/TicketDisplay.vue'
import TicketList from '@/components/ticket/TicketList.vue'
import { useQuery } from '@tanstack/vue-query'
import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase'
import type { Type } from '@/lib/types'
const route = useRoute()
const router = useRouter()
const id = computed(() => route.params.id as string)
const type = computed(() => route.params.type as string)
const {
isPending,
isError,
data: selectedType,
error,
refetch
} = useQuery({
queryKey: ['types', type.value],
queryFn: (): Promise<Type> => pb.collection('types').getOne(type.value)
})
watch(
() => type.value,
() => refetch()
)
onMounted(() => {
if (!pb.authStore.model) {
router.push({ name: 'login' })
}
})
</script>
<template>
<ThreeColumn>
<template #list>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="selectedType">
<TicketList v-if="selectedType" :key="selectedType.id" :selectedType="selectedType" />
</TanView>
</template>
<template #single>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="selectedType">
<div
v-if="!id"
class="flex h-full w-full items-center justify-center text-lg text-gray-500"
>
No ticket selected
</div>
<TicketDisplay v-else-if="selectedType" :key="id" :selectedType="selectedType" />
</TanView>
</template>
</ThreeColumn>
</template>
-138
View File
@@ -1,138 +0,0 @@
<template>
<div>
<div v-if="user === 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="fill-height d-flex flex-column pa-8">
<div v-if="$route.params.id === 'new'">
<h2>
Create a new
<span v-if="user.apikey">API Key</span>
<span v-else>User</span>
</h2>
<i>Users can only be created via OIDC.</i>
<v-form>
<!--v-btn-toggle v-model="user.apikey" mandatory dense>
<v-btn :value="false">User</v-btn>
<v-btn :value="true">API Key</v-btn>
</v-btn-toggle-->
<v-text-field label="ID" v-model="user.id" class="mb-2" :rules="[
v => !!v || 'ID is required',
v => (v && v.length < 254) || 'ID must be between 1 and 254 characters',
v => /^[a-z\d\-]+$/.test(v) || 'Only characters a-z, 0-9 and - are allowed',
// v => /^[A-Za-z0-9_\-\:@\(\)\+,=;\$!\*'%]+$/.test(v) || 'Only characters A-Z, a-z, 0-9, _, -, :, ., @, (, ), +, ,, =, ;, $, !, *, \', % are allowed',
]"></v-text-field>
<v-text-field
v-if="!user.apikey"
label="Password"
v-model="user.password"
:append-icon="show ? 'mdi-eye' : 'mdi-eye-off'"
:type="show ? 'text' : 'password'"
@click:append="show = !show"
:rules="[
v => !!v || 'Password is required',
v => (v && v.length >= 8) || 'Password must be at least 8 characters',
]"></v-text-field>
<v-select multiple chips label="Roles" v-model="user.roles" :items="['analyst', 'engineer', 'admin']"></v-select>
<v-btn @click="save" color="success" outlined>
<v-icon>mdi-plus-thick</v-icon>
Create
<span v-if="user.apikey" class="ml-1">API Key</span>
<span v-else class="ml-1">User</span>
</v-btn>
</v-form>
<v-alert v-if="newUserResponse" color="warning" class="mt-4" dismissible>
<b>New API secret:</b> {{ newUserResponse.secret }}<br>
Make sure you save it - you won't be able to access it again.
</v-alert>
</div>
<div v-else>
<h2>
{{ user.id }}
<span v-if="user.apikey">(API Key)</span>
</h2>
<!--v-text-field v-if="!user.apikey" label="New Password (leave empty to keep)" v-model="user.password" hide-details class="mb-4"></v-text-field-->
<v-checkbox v-if="!user.apikey" label="Blocked" v-model="user.blocked" hide-details class="mb-4"></v-checkbox>
<v-select multiple chips v-if="!user.apikey" label="Roles" v-model="user.roles" :items="['analyst', 'engineer', 'admin']"></v-select>
<div v-else>
<v-chip v-for="role in user.roles" :key="role" class="mr-1 mb-1">{{ role }}</v-chip>
</div>
<v-btn v-if="!user.apikey" @click="save" color="success" outlined>
<v-icon>mdi-content-save</v-icon>
Save
</v-btn>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import {NewUserResponse, UserForm} from "@/client";
import {API} from "@/services/api";
interface State {
show: boolean;
user?: UserForm;
newUserResponse?: NewUserResponse;
}
export default Vue.extend({
name: "User",
components: {},
data: (): State => ({
show: false,
user: undefined,
newUserResponse: undefined
}),
watch: {
'$route': function () {
this.loadUser();
this.newUserResponse = undefined;
}
},
methods: {
save() {
if (!this.user) {
return
}
if (this.$route.params.id == 'new') {
API.createUser(this.user).then(response => {
this.newUserResponse = response.data;
})
} else {
API.updateUser(this.$route.params.id, this.user).then(response => {
this.user = response.data;
})
}
},
loadUser() {
if (!this.$route.params.id) {
return
}
if (this.$route.params.id == 'new') {
this.user = {id: "", roles: [], blocked: false, apikey: true}
} else {
API.getUser(this.$route.params.id).then((response) => {
this.user = response.data;
});
}
},
},
mounted() {
this.loadUser();
},
});
</script>
-44
View File
@@ -1,44 +0,0 @@
<template>
<div v-if="userdata">
<user-data-editor :userdata="userdata" @save="saveUserData"></user-data-editor>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { UserData } from "@/client";
import { API } from "@/services/api";
import UserDataEditor from "@/components/UserDataEditor.vue";
interface State {
userdata?: UserData;
}
export default Vue.extend({
name: "UserData",
data: (): State => ({
userdata: undefined,
}),
components: { UserDataEditor },
watch: {
$route: function () {
this.loadUserData();
},
},
methods: {
saveUserData: function(userdata: UserData) {
API.updateUserData(this.$route.params.id, userdata).then(() => {
this.$store.dispatch("alertSuccess", { name: "User data saved" });
});
},
loadUserData: function () {
API.getUserData(this.$route.params.id).then((response) => {
this.userdata = response.data;
});
}
},
mounted() {
this.loadUserData();
}
});
</script>
-60
View File
@@ -1,60 +0,0 @@
<template>
<v-main style="min-height: 100vh;">
<List
:items="userdatas"
routername="UserData"
itemid="id"
itemname="name"
singular="User Data"
plural="User Data"
:show-new="false"
:deletable="false"
writepermission="userdata:write"
></List>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {UserData} from "@/client";
import {API} from "@/services/api";
import List from "../components/List.vue";
interface State {
userdatas: Array<UserData>;
}
export default Vue.extend({
name: "UserDataList",
components: {List},
data: (): State => ({
userdatas: [],
}),
methods: {
loadSettings() {
API.listUserData().then((response) => {
this.userdatas = response.data;
});
},
},
mounted() {
this.loadSettings();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "settings/")) {
reload = true;
}
});
if (reload) {
this.loadSettings()
}
})
},
});
</script>
-66
View File
@@ -1,66 +0,0 @@
<template>
<v-main style="min-height: 100vh;">
<List
:items="users"
routername="User"
itemid="id"
itemname="id"
singular="User / API Key"
plural="Users / API Keys"
@delete="deleteUser"
writepermission="user:write"
></List>
</v-main>
</template>
<script lang="ts">
import Vue from "vue";
import {UserResponse} from "@/client";
import {API} from "@/services/api";
import List from "../components/List.vue";
interface State {
users: Array<UserResponse>;
}
export default Vue.extend({
name: "UserList",
components: {List},
data: (): State => ({
users: [],
}),
methods: {
loadUsers() {
API.listUsers().then((response) => {
if (response.data) {
this.users = response.data;
}
});
},
deleteUser(name: string) {
API.deleteUser(name).then(() => {
this.loadUsers();
});
},
},
mounted() {
this.loadUsers();
this.$store.subscribeAction((action, state) => {
if (!action.payload || !(this.lodash.has(action.payload, "ids")) || !action.payload["ids"]) {
return
}
let reload = false;
Vue.lodash.forEach(action.payload["ids"], (id) => {
if (this.lodash.startsWith(id, "users/")) {
reload = true;
}
});
if (reload) {
this.loadUsers()
}
})
},
});
</script>
-21
View File
@@ -1,21 +0,0 @@
<template>
<object data="http://localhost:8003">
<embed src="http://localhost:8003" />
Error: Embedded data could not be displayed.
</object>
</template>
<script>
export default {
name: "Arango",
};
</script>
<style scoped>
object,
embed {
padding-left: 56px;
height: 100%;
width: 100%;
}
</style>
-21
View File
@@ -1,21 +0,0 @@
<template>
<object data="http://localhost:9000/minio">
<embed src="http://localhost:9000/minio" />
Error: Embedded data could not be displayed.
</object>
</template>
<script>
export default {
name: "Minio",
};
</script>
<style scoped>
object,
embed {
padding-left: 56px;
height: 100%;
width: 100%;
}
</style>
-21
View File
@@ -1,21 +0,0 @@
<template>
<object data="http://localhost:1880">
<embed src="http://localhost:1880" />
Error: Embedded data could not be displayed.
</object>
</template>
<script>
export default {
name: "Nodered",
};
</script>
<style scoped>
object,
embed {
padding-left: 56px;
height: 100%;
width: 100%;
}
</style>