Compare commits

...

4 Commits

Author SHA1 Message Date
Jonas Plum
96b7a9604c fix: multi select state handling (#1094) 2024-08-05 15:22:01 +02:00
Jonas Plum
21f1c3d328 feat: reset password (#1092) 2024-08-03 16:26:09 +02:00
Jonas Plum
84ae933cfb perf: search (#1091) 2024-08-03 14:58:55 +02:00
Jonas Plum
b929100d30 feat: enum custom field (#1090) 2024-08-03 13:43:41 +02:00
18 changed files with 299 additions and 84 deletions

11
.github/codecov.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
coverage:
status:
project:
default:
threshold: 5%
patch: off
comment:
layout: diff
parsers:
go:
partials_as_hits: true

View File

@@ -48,6 +48,15 @@ dev:
go run . fake-data
go run . serve
.PHONY: dev-10000
dev-10000:
@echo "Running..."
rm -rf catalyst_data
go run . admin create admin@catalyst-soar.com 1234567890
go run . set-feature-flags dev
go run . fake-data --users 100 --tickets 10000
go run . serve
.PHONY: dev-ui
serve-ui:
cd ui && bun dev --port 3000

View File

@@ -128,7 +128,7 @@ func ticketRecords(dao *daos.Dao, users, types []*models.Record, count int) []*m
record.Set("description", fakeTicketDescription())
record.Set("open", gofakeit.Bool())
record.Set("schema", `{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`)
record.Set("state", `{"tlp":"AMBER"}`)
record.Set("state", `{"severity":"Medium"}`)
record.Set("owner", random(users).GetId())
records = append(records, record)

View File

@@ -1,6 +1,8 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
@@ -33,7 +35,16 @@ func typeRecords(dao *daos.Dao) []*models.Record {
record.Set("singular", "Incident")
record.Set("plural", "Incidents")
record.Set("icon", "Flame")
record.Set("schema", `{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`)
record.Set("schema", s(map[string]any{
"type": "object",
"properties": map[string]any{
"severity": map[string]any{
"title": "Severity",
"enum": []string{"Low", "Medium", "High"},
},
},
"required": []string{"severity"},
}))
records = append(records, record)
@@ -42,9 +53,24 @@ func typeRecords(dao *daos.Dao) []*models.Record {
record.Set("singular", "Alert")
record.Set("plural", "Alerts")
record.Set("icon", "AlertTriangle")
record.Set("schema", `{"type":"object","properties":{"severity":{"title":"Severity","type":"string"}},"required": ["severity"]}`)
record.Set("schema", s(map[string]any{
"type": "object",
"properties": map[string]any{
"severity": map[string]any{
"title": "Severity",
"enum": []string{"Low", "Medium", "High"},
},
},
"required": []string{"severity"},
}))
records = append(records, record)
return records
}
func s(m map[string]any) string {
b, _ := json.Marshal(m) //nolint:errchkjson
return string(b)
}

View File

@@ -0,0 +1,49 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
)
const searchViewName = "ticket_search"
const searchViewQuery = `
SELECT
tickets.id,
tickets.name,
tickets.created,
tickets.description,
tickets.open,
tickets.type,
tickets.state,
users.name as owner_name,
group_concat(comments.message) as comment_messages,
group_concat(files.name) as file_names,
group_concat(links.name) as link_names,
group_concat(links.url) as link_urls,
group_concat(tasks.name) as task_names,
group_concat(timeline.message) as timeline_messages
FROM tickets
LEFT JOIN comments ON comments.ticket = tickets.id
LEFT JOIN files ON files.ticket = tickets.id
LEFT JOIN links ON links.ticket = tickets.id
LEFT JOIN tasks ON tasks.ticket = tickets.id
LEFT JOIN timeline ON timeline.ticket = tickets.id
LEFT JOIN users ON users.id = tickets.owner
GROUP BY tickets.id
`
func searchViewUp(db dbx.Builder) error {
return daos.New(db).SaveCollection(internalView(searchViewName, searchViewQuery))
}
func searchViewDown(db dbx.Builder) error {
dao := daos.New(db)
id, err := dao.FindCollectionByNameOrId(searchViewName)
if err != nil {
return err
}
return dao.DeleteCollection(id)
}

View File

@@ -11,4 +11,5 @@ func Register() {
migrations.Register(viewsUp, viewsDown, "1700000004_views.go")
migrations.Register(reactionsUp, reactionsDown, "1700000005_reactions.go")
migrations.Register(systemuserUp, systemuserDown, "1700000006_systemuser.go")
migrations.Register(searchViewUp, searchViewDown, "1700000007_search_view.go")
}

View File

@@ -9,7 +9,7 @@
"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",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path ../.gitignore",
"format": "prettier --write src/"
},
"dependencies": {

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<img src="@/assets/flask.svg" alt="Catalyst Logo" :class="cn('dark:hidden', props.class)" />
<img
src="@/assets/flask_white.svg"
alt="Catalyst Logo"
:class="cn('hidden dark:flex', props.class)"
/>
</template>

View File

@@ -1,24 +1,14 @@
<script setup lang="ts">
import { Textarea } from '@/components/ui/textarea'
import { useVModel } from '@vueuse/core'
import { type HTMLAttributes, onMounted, ref } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
defaultValue?: string | number
modelValue?: string | number
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue
const modelValue = defineModel<string>({
default: ''
})
const textarea = ref<HTMLElement | null>(null)

View File

@@ -2,6 +2,14 @@
import { Checkbox } from '@/components/ui/checkbox'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { onMounted, ref, watch } from 'vue'
@@ -34,6 +42,26 @@ watch(
<template>
<div v-for="(property, key) in schema.properties" :key="key">
<FormField v-if="property.enum" :name="key" v-slot="{ componentField }" v-model="formdata[key]">
<FormItem>
<FormLabel :for="key" class="text-right">
{{ property.title }}
</FormLabel>
<Select :id="key" class="col-span-3" v-bind="componentField">
<SelectTrigger class="font-medium">
<SelectValue :placeholder="'Select a ' + property.title" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="option in property.enum" :key="option" :value="option">
{{ option }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-if="property.type === 'string'"
:name="key"

View File

@@ -32,7 +32,8 @@ const selectedItems = ref<string[]>(props.modelValue)
watch(
() => selectedItems.value,
(value) => emit('update:modelValue', value)
(value) => emit('update:modelValue', value),
{ deep: true }
)
const filteredItems = computed(() => {

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import CatalystLogo from '@/components/common/CatalystLogo.vue'
import IncidentNav from '@/components/sidebar/IncidentNav.vue'
import NavList from '@/components/sidebar/NavList.vue'
import UserDropDown from '@/components/sidebar/UserDropDown.vue'
@@ -14,16 +15,8 @@ const catalystStore = useCatalystStore()
<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"
<CatalystLogo
class="size-8"
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }"
/>
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>

View File

@@ -17,9 +17,8 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Info, LoaderCircle, Search } from 'lucide-vue-next'
import { LoaderCircle, Search } from 'lucide-vue-next'
import { useQuery } from '@tanstack/vue-query'
import debounce from 'lodash.debounce'
@@ -28,7 +27,7 @@ import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase'
import type { Ticket, Type } from '@/lib/types'
import type { SearchTicket, Type } from '@/lib/types'
const router = useRouter()
const route = useRoute()
@@ -48,15 +47,13 @@ const filter = computed(() => {
let raws: Array<string> = [
'name ~ {:search}',
'description ~ {:search}',
'owner.name ~ {:search}',
'owner.email ~ {:search}',
'links_via_ticket.name ~ {:search}',
'links_via_ticket.url ~ {:search}',
'tasks_via_ticket.name ~ {:search}',
'comments_via_ticket.message ~ {:search}',
'files_via_ticket.name ~ {:search}',
'timeline_via_ticket.message ~ {:search}',
'state.severity ~ {:search}'
'owner_name ~ {:search}',
'comment_messages ~ {:search}',
'file_names ~ {:search}',
'link_names ~ {:search}',
'link_urls ~ {:search}',
'task_names ~ {:search}',
'timeline_messages ~ {:search}'
]
Object.keys(props.selectedType.schema.properties).forEach((key) => {
@@ -96,12 +93,10 @@ const {
refetch
} = useQuery({
queryKey: ['tickets', filter.value],
queryFn: (): Promise<ListResult<Ticket>> =>
pb.collection('tickets').getList(page.value, perPage.value, {
queryFn: (): Promise<ListResult<SearchTicket>> =>
pb.collection('ticket_search').getList(page.value, perPage.value, {
sort: '-created',
filter: filter.value,
expand:
'type,owner,comments_via_ticket.author,files_via_ticket,timeline_via_ticket,links_via_ticket,tasks_via_ticket.owner'
filter: filter.value
})
})
@@ -139,9 +134,9 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
<Tabs v-model="tab" class="flex flex-1 flex-col overflow-hidden">
<div class="flex items-center justify-between px-4 pt-2">
<TabsList>
<TabsTrigger value="all"> All</TabsTrigger>
<TabsTrigger value="open"> Open</TabsTrigger>
<TabsTrigger value="closed"> Closed</TabsTrigger>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="open">Open</TabsTrigger>
<TabsTrigger value="closed">Closed</TabsTrigger>
</TabsList>
<!-- Button variant="outline" size="sm" class="h-7 gap-1 rounded-md px-3">
<ListFilter class="h-3.5 w-3.5" />
@@ -155,23 +150,6 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
<span class="absolute inset-y-0 start-0 flex items-center justify-center px-2">
<Search class="size-4 text-muted-foreground" />
</span>
<div>
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger as-child>
<Info class="ml-2 size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p class="w-64">
Search name, description, or owner. Links, tasks, comments, files, and
timeline messages are also searched, but cause unreliable results if there are
more than 1000 records.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</form>
</div>
@@ -222,7 +200,6 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
</PaginationListItem>
<PaginationEllipsis v-else :key="item.type" :index="index" />
</template>
<PaginationNext />
<PaginationLast />
</PaginationList>

View File

@@ -3,12 +3,12 @@ import ResourceListElement from '@/components/common/ResourceListElement.vue'
import { useRoute } from 'vue-router'
import type { Ticket } from '@/lib/types'
import type { SearchTicket } from '@/lib/types'
const route = useRoute()
defineProps<{
tickets: Array<Ticket>
tickets: Array<SearchTicket>
}>()
</script>
@@ -19,10 +19,10 @@ defineProps<{
:key="item.id"
:title="item.name"
:created="item.created"
:subtitle="item.expand.owner ? item.expand.owner.name : ''"
:subtitle="item.owner_name"
:description="item.description ? item.description.substring(0, 300) : ''"
:active="route.params.id === item.id"
:to="`/tickets/${item.expand.type.id}/${item.id}`"
:to="`/tickets/${item.type}/${item.id}`"
:open="item.open"
/>
</div>

View File

@@ -32,6 +32,16 @@ export interface Ticket {
}
}
export interface SearchTicket {
id: string
name: string
created: string
description: string
open: boolean
type: string
owner_name: string
}
export interface Task {
id: string
@@ -126,6 +136,7 @@ export interface JSONSchema {
title: string
type: string
description?: string
enum?: Array<string>
}
>
required?: Array<string>

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import DashboardView from '@/views/DashboardView.vue'
import LoginView from '@/views/LoginView.vue'
import PasswordResetView from '@/views/PasswordResetView.vue'
import ReactionView from '@/views/ReactionView.vue'
import TicketView from '@/views/TicketView.vue'
@@ -12,25 +13,30 @@ const router = createRouter({
path: '/',
redirect: '/dashboard'
},
{
path: '/reactions/:id?',
name: 'reactions',
component: ReactionView
},
{
path: '/dashboard',
name: 'dashboard',
component: DashboardView
},
{
path: '/tickets/:type/:id?',
name: 'tickets',
component: TicketView
},
{
path: '/reactions/:id?',
name: 'reactions',
component: ReactionView
},
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/tickets/:type/:id?',
name: 'tickets',
component: TicketView
path: '/password-reset',
name: 'password-reset',
component: PasswordResetView
}
]
})

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import CatalystLogo from '@/components/common/CatalystLogo.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Button, buttonVariants } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -8,9 +9,11 @@ import { useQuery } from '@tanstack/vue-query'
import { ref, watch } from 'vue'
import { pb } from '@/lib/pocketbase'
import { cn } from '@/lib/utils'
const mail = ref('')
const password = ref('')
const errorTitle = ref('')
const errorMessage = ref('')
const login = () => {
@@ -20,6 +23,7 @@ const login = () => {
window.location.href = '/ui/'
})
.catch((error) => {
errorTitle.value = 'Login failed'
errorMessage.value = error.message
})
}
@@ -37,7 +41,8 @@ watch(
mail.value = 'user@catalyst-soar.com'
password.value = '1234567890'
}
}
},
{ immediate: true }
)
</script>
@@ -45,12 +50,18 @@ watch(
<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>
<CardTitle class="flex flex-row">
<CatalystLogo class="size-12" />
<div>
<h1 class="text-lg font-bold">Catalyst</h1>
<div class="text-muted-foreground">Login</div>
</div>
</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 v-if="errorTitle || errorMessage" variant="destructive" class="border-4 p-4">
<AlertTitle v-if="errorTitle">{{ errorTitle }}</AlertTitle>
<AlertDescription v-if="errorMessage">{{ errorMessage }}</AlertDescription>
</Alert>
<Input
v-model="mail"
@@ -66,7 +77,14 @@ watch(
class="w-full"
@keydown.enter="login"
/>
<Button variant="outline" class="w-full" @click="login"> Login</Button>
<Button variant="outline" class="w-full" @click="login">Login</Button>
<RouterLink
:to="{ name: 'password-reset' }"
:class="
cn(buttonVariants({ variant: 'link', size: 'default' }), 'w-full text-foreground')
"
>Reset Password
</RouterLink>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import CatalystLogo from '@/components/common/CatalystLogo.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button, buttonVariants } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { ref } from 'vue'
import { pb } from '@/lib/pocketbase'
import { cn } from '@/lib/utils'
interface AlertData {
variant: 'default' | 'destructive'
title: string
message: string
}
const mail = ref('')
const alert = ref<AlertData | null>(null)
const resetPassword = () => {
pb.collection('users')
.requestPasswordReset(mail.value)
.then(() => {
alert.value = {
variant: 'default',
title: 'Password reset',
message: 'Password reset email sent'
}
})
.catch((error) => {
alert.value = {
variant: 'destructive',
title: 'Password reset failed',
message: error.message
}
})
}
</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 class="flex flex-row">
<CatalystLogo class="size-12" />
<div>
<h1 class="text-lg font-bold">Catalyst</h1>
<div class="text-muted-foreground">Password Reset</div>
</div>
</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<Alert v-if="alert" :variant="alert.variant" class="border-4 p-4">
<AlertTitle>{{ alert.title }}</AlertTitle>
<AlertDescription>{{ alert.message }}</AlertDescription>
</Alert>
<div v-else class="flex flex-col gap-4">
<Input
v-model="mail"
type="text"
placeholder="Email"
class="w-full"
@keydown.enter="resetPassword"
/>
<Button variant="outline" class="w-full" @click="resetPassword">Reset Password</Button>
</div>
<RouterLink
:to="{ name: 'login' }"
:class="
cn(buttonVariants({ variant: 'link', size: 'default' }), 'w-full text-foreground')
"
>Back to Login
</RouterLink>
</CardContent>
</Card>
</div>
</template>