Compare commits

..

12 Commits

Author SHA1 Message Date
Jonas Plum
88f56a2bdb fix: ci docker login 2024-09-30 03:45:24 +02:00
Jonas Plum
88cc02b350 fix: goreleaser ci permissions (#1105) 2024-09-30 03:30:29 +02:00
Jonas Plum
46f7815699 feat: docker files (#1104) 2024-09-30 03:20:26 +02:00
Jonas Plum
ea03a3ed23 fix: prevent view update (#1102) 2024-09-20 00:02:15 +02:00
Jonas Plum
6346140de5 fix: multiple hooks (#1101) 2024-09-19 23:23:45 +02:00
Jonas Plum
d7bdf1d276 fix: curl example (#1099) 2024-08-13 08:09:46 +02:00
Jonas Plum
1e1022ab15 fix: reaction names (#1098) 2024-08-13 07:44:06 +02:00
Jonas Plum
a2dd6c05e6 feat: mobile ui (#1096) 2024-08-07 22:18:59 +02:00
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
90 changed files with 1045 additions and 1410 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

@@ -7,6 +7,8 @@ on:
permissions: permissions:
contents: write contents: write
id-token: write
packages: write
jobs: jobs:
goreleaser: goreleaser:
@@ -21,6 +23,11 @@ jobs:
- run: make build-ui - run: make build-ui
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: "securitybrewery"
password: ${{ secrets.GITHUB_TOKEN }}
- uses: goreleaser/goreleaser-action@v6 - uses: goreleaser/goreleaser-action@v6
with: with:
distribution: goreleaser distribution: goreleaser

View File

@@ -11,6 +11,15 @@ builds:
- linux - linux
- darwin - darwin
dockers:
- ids: [ catalyst ]
dockerfile: docker/goreleaser.Dockerfile
image_templates:
- "ghcr.io/securitybrewery/catalyst:latest"
- "ghcr.io/securitybrewery/catalyst:{{.Tag}}"
- "ghcr.io/securitybrewery/catalyst:v{{.Major}}"
- "ghcr.io/securitybrewery/catalyst:v{{.Major}}.{{.Minor}}"
archives: archives:
- format: tar.gz - format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`. # this name template makes the OS and Arch compatible with the results of `uname`.

View File

@@ -48,6 +48,15 @@ dev:
go run . fake-data go run . fake-data
go run . serve go run . serve
.PHONY: dev-ui .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: serve-ui
serve-ui: serve-ui:
cd ui && bun dev --port 3000 cd ui && bun dev --port 3000

24
docker/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM oven/bun:debian
RUN apt-get update && apt-get install -y make
COPY .. /tmp/catalyst
WORKDIR /tmp/catalyst
RUN make build-ui
FROM golang:1.23
COPY --from=0 /tmp/catalyst /tmp/catalyst
WORKDIR /tmp/catalyst
RUN go build -o /usr/local/bin/catalyst
FROM ubuntu:24.04
COPY --from=1 /usr/local/bin/catalyst /usr/local/bin/catalyst
EXPOSE 8080
VOLUME /usr/local/bin/catalyst_data
CMD ["/usr/local/bin/catalyst", "serve", "--http", "0.0.0.0:8080"]

View File

@@ -0,0 +1,9 @@
FROM ubuntu:24.04
COPY catalyst /usr/local/bin/catalyst
EXPOSE 8080
VOLUME /usr/local/bin/catalyst_data
CMD ["/usr/local/bin/catalyst", "serve", "--http", "0.0.0.0:8080"]

View File

@@ -128,7 +128,7 @@ func ticketRecords(dao *daos.Dao, users, types []*models.Record, count int) []*m
record.Set("description", fakeTicketDescription()) record.Set("description", fakeTicketDescription())
record.Set("open", gofakeit.Bool()) record.Set("open", gofakeit.Bool())
record.Set("schema", `{"type":"object","properties":{"tlp":{"title":"TLP","type":"string"}}}`) 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()) record.Set("owner", random(users).GetId())
records = append(records, record) records = append(records, record)
@@ -316,7 +316,7 @@ func reactionRecords(dao *daos.Dao) []*models.Record {
record := models.NewRecord(collection) record := models.NewRecord(collection)
record.SetId("w_" + security.PseudorandomString(10)) record.SetId("w_" + security.PseudorandomString(10))
record.Set("name", "Test Reaction") record.Set("name", "Alert Ingest Webhook")
record.Set("trigger", "webhook") record.Set("trigger", "webhook")
record.Set("triggerdata", triggerWebhook) record.Set("triggerdata", triggerWebhook)
record.Set("action", "python") record.Set("action", "python")
@@ -334,7 +334,7 @@ func reactionRecords(dao *daos.Dao) []*models.Record {
record = models.NewRecord(collection) record = models.NewRecord(collection)
record.SetId("w_" + security.PseudorandomString(10)) record.SetId("w_" + security.PseudorandomString(10))
record.Set("name", "Test Reaction 2") record.Set("name", "Assign new Tickets")
record.Set("trigger", "hook") record.Set("trigger", "hook")
record.Set("triggerdata", triggerHook) record.Set("triggerdata", triggerHook)
record.Set("action", "python") record.Set("action", "python")

1
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
github.com/tidwall/sjson v1.2.5 github.com/tidwall/sjson v1.2.5
go.uber.org/multierr v1.11.0
) )
require ( require (

2
go.sum
View File

@@ -229,6 +229,8 @@ go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D
go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s= go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s=
go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM= go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM=
go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I= go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
gocloud.dev v0.37.0 h1:XF1rN6R0qZI/9DYjN16Uy0durAmSlf58DHOcb28GPro= gocloud.dev v0.37.0 h1:XF1rN6R0qZI/9DYjN16Uy0durAmSlf58DHOcb28GPro=
gocloud.dev v0.37.0/go.mod h1:7/O4kqdInCNsc6LqgmuFnS0GRew4XNNYWpA44yQnwco= gocloud.dev v0.37.0/go.mod h1:7/O4kqdInCNsc6LqgmuFnS0GRew4XNNYWpA44yQnwco=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

View File

@@ -1,6 +1,8 @@
package migrations package migrations
import ( import (
"encoding/json"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
@@ -33,7 +35,16 @@ func typeRecords(dao *daos.Dao) []*models.Record {
record.Set("singular", "Incident") record.Set("singular", "Incident")
record.Set("plural", "Incidents") record.Set("plural", "Incidents")
record.Set("icon", "Flame") 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) records = append(records, record)
@@ -42,9 +53,24 @@ func typeRecords(dao *daos.Dao) []*models.Record {
record.Set("singular", "Alert") record.Set("singular", "Alert")
record.Set("plural", "Alerts") record.Set("plural", "Alerts")
record.Set("icon", "AlertTriangle") 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) records = append(records, record)
return records 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

@@ -0,0 +1,43 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tools/types"
)
const dashboardCountsViewUpdateQuery = `SELECT id, count FROM (
SELECT 'users' as id, COUNT(users.id) as count FROM users
UNION
SELECT 'tickets' as id, COUNT(tickets.id) as count FROM tickets
UNION
SELECT 'tasks' as id, COUNT(tasks.id) as count FROM tasks
UNION
SELECT 'reactions' as id, COUNT(reactions.id) as count FROM reactions
) as counts;`
func dashboardCountsViewUpdateUp(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId(dashboardCountsViewName)
if err != nil {
return err
}
collection.Options = types.JsonMap{"query": dashboardCountsViewUpdateQuery}
return dao.SaveCollection(collection)
}
func dashboardCountsViewUpdateDown(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId(dashboardCountsViewName)
if err != nil {
return err
}
collection.Options = types.JsonMap{"query": dashboardCountsViewQuery}
return dao.SaveCollection(collection)
}

View File

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

View File

@@ -14,6 +14,7 @@ import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos" "github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"go.uber.org/multierr"
"github.com/SecurityBrewery/catalyst/migrations" "github.com/SecurityBrewery/catalyst/migrations"
"github.com/SecurityBrewery/catalyst/reaction/action" "github.com/SecurityBrewery/catalyst/reaction/action"
@@ -70,43 +71,49 @@ func runHook(ctx context.Context, app core.App, collection, event string, record
return fmt.Errorf("failed to marshal webhook payload: %w", err) return fmt.Errorf("failed to marshal webhook payload: %w", err)
} }
hook, found, err := findByHookTrigger(app.Dao(), collection, event) hooks, err := findByHookTrigger(app.Dao(), collection, event)
if err != nil { if err != nil {
return fmt.Errorf("failed to find hook by trigger: %w", err) return fmt.Errorf("failed to find hook by trigger: %w", err)
} }
if !found { if len(hooks) == 0 {
return nil return nil
} }
_, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), string(payload)) var errs error
if err != nil {
return fmt.Errorf("failed to run hook reaction: %w", err) for _, hook := range hooks {
_, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), string(payload))
if err != nil {
errs = multierr.Append(errs, fmt.Errorf("failed to run hook reaction: %w", err))
}
} }
return nil return errs
} }
func findByHookTrigger(dao *daos.Dao, collection, event string) (*models.Record, bool, error) { func findByHookTrigger(dao *daos.Dao, collection, event string) ([]*models.Record, error) {
records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "hook"}) records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "hook"})
if err != nil { if err != nil {
return nil, false, fmt.Errorf("failed to find hook reaction: %w", err) return nil, fmt.Errorf("failed to find hook reaction: %w", err)
} }
if len(records) == 0 { if len(records) == 0 {
return nil, false, nil return nil, nil
} }
var matchedRecords []*models.Record
for _, record := range records { for _, record := range records {
var hook Hook var hook Hook
if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &hook); err != nil { if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &hook); err != nil {
return nil, false, err return nil, err
} }
if slices.Contains(hook.Collections, collection) && slices.Contains(hook.Events, event) { if slices.Contains(hook.Collections, collection) && slices.Contains(hook.Events, event) {
return record, true, nil matchedRecords = append(matchedRecords, record)
} }
} }
return nil, false, nil return matchedRecords, nil
} }

Binary file not shown.

View File

@@ -7,7 +7,7 @@
<title>Catalyst</title> <title>Catalyst</title>
</head> </head>
<body> <body>
<div id="app" class="h-screen w-screen"></div> <div id="app" class="h-screen w-screen overflow-hidden"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -9,7 +9,7 @@
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build --force", "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/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
@@ -28,6 +28,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"easymde": "^2.18.0", "easymde": "^2.18.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lucide-vue-next": "^0.365.0", "lucide-vue-next": "^0.365.0",
"marked": "^12.0.2", "marked": "^12.0.2",
"pinia": "^2.1.7", "pinia": "^2.1.7",
@@ -48,6 +49,7 @@
"@trivago/prettier-plugin-sort-imports": "^4.3.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@tsconfig/node20": "^20.1.2", "@tsconfig/node20": "^20.1.2",
"@types/lodash.debounce": "^4.0.9", "@types/lodash.debounce": "^4.0.9",
"@types/lodash.isequal": "^4.5.8",
"@types/node": "^20.11.28", "@types/node": "^20.11.28",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-prettier": "^8.0.0",

View File

@@ -7,17 +7,16 @@ defineProps<{
isPending: boolean isPending: boolean
isError: boolean isError: boolean
error: Error | null error: Error | null
value: any
}>() }>()
</script> </script>
<template> <template>
<div v-if="isPending" class="flex justify-center"> <div v-if="isPending" class="flex h-full w-full">
<LoaderCircle class="h-16 w-16 animate-spin text-primary" /> <LoaderCircle class="m-auto h-16 w-16 animate-spin text-primary" />
</div> </div>
<Alert v-else-if="isError" variant="destructive" class="mb-4"> <Alert v-else-if="isError" variant="destructive" class="mb-4">
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription> <AlertDescription>{{ error }}</AlertDescription>
</Alert> </Alert>
<slot v-else-if="value" /> <slot v-else />
</template> </template>

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,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import PanelListElement from '@/components/common/PanelListElement.vue' import TanView from '@/components/TanView.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import { buttonVariants } from '@/components/ui/button' import { buttonVariants } from '@/components/ui/button'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
@@ -32,24 +33,31 @@ const {
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Card> <Card>
<div v-if="tasks && tasks.length === 0" class="p-2 text-center text-sm text-gray-500"> <TanView :isError="isError" :isPending="isPending" :error="error">
No open tasks <div v-if="tasks && tasks.length === 0" class="p-2 text-center text-sm text-gray-500">
</div> No open tasks
<PanelListElement v-else v-for="task in tasks" :key="task.id" class="pr-1"> </div>
<span>{{ task.name }}</span> <PanelListElement v-else v-for="task in tasks" :key="task.id" class="pr-1">
<RouterLink <span>{{ task.name }}</span>
:to="{ <RouterLink
name: 'tickets', :to="{
params: { type: task.expand.ticket.type, id: task.expand.ticket.id } name: 'tickets',
}" params: { type: task.expand.ticket.type, id: task.expand.ticket.id }
:class="cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'ml-auto h-8')" }"
> :class="
<span class="flex flex-row items-center text-sm text-gray-500"> cn(
Go to {{ task.expand.ticket.name }} buttonVariants({ variant: 'outline', size: 'sm' }),
<ChevronRight class="ml-2 h-4 w-4" /> 'h-8 w-full sm:ml-auto sm:w-auto'
</span> )
</RouterLink> "
</PanelListElement> >
<span class="flex flex-row items-center text-sm text-gray-500">
Go to {{ task.expand.ticket.name }}
<ChevronRight class="ml-2 h-4 w-4" />
</span>
</RouterLink>
</PanelListElement>
</TanView>
</Card> </Card>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PanelListElement from '@/components/common/PanelListElement.vue' import PanelListElement from '@/components/layout/PanelListElement.vue'
import { buttonVariants } from '@/components/ui/button' import { buttonVariants } from '@/components/ui/button'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
@@ -42,16 +42,21 @@ const age = (ticket: Ticket) =>
</div> </div>
<PanelListElement v-else v-for="ticket in tickets" :key="ticket.id" class="gap-2 pr-1"> <PanelListElement v-else v-for="ticket in tickets" :key="ticket.id" class="gap-2 pr-1">
<span>{{ ticket.name }}</span> <span>{{ ticket.name }}</span>
<Separator orientation="vertical" class="h-4" /> <Separator orientation="vertical" class="hidden h-4 sm:block" />
<span class="text-sm text-muted-foreground">{{ ticket.expand.type.singular }}</span> <span class="text-sm text-muted-foreground">{{ ticket.expand.type.singular }}</span>
<Separator orientation="vertical" class="h-4" /> <Separator orientation="vertical" class="hidden h-4 sm:block" />
<span class="text-sm text-muted-foreground">Open since {{ age(ticket) }} days</span> <span class="text-sm text-muted-foreground">Open since {{ age(ticket) }} days</span>
<RouterLink <RouterLink
:to="{ :to="{
name: 'tickets', name: 'tickets',
params: { type: ticket.type, id: ticket.id } params: { type: ticket.type, id: ticket.id }
}" }"
:class="cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'ml-auto h-8')" :class="
cn(
buttonVariants({ variant: 'outline', size: 'sm' }),
'h-8 w-full sm:ml-auto sm:w-auto'
)
"
> >
<span class="flex flex-row items-center text-sm text-gray-500"> <span class="flex flex-row items-center text-sm text-gray-500">
Go to {{ ticket.name }} Go to {{ ticket.name }}

View File

@@ -43,7 +43,7 @@ const ticketsPerWeek = computed(() => {
</script> </script>
<template> <template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="tickets"> <TanView :isError="isError" :isPending="isPending" :error="error">
<LineChart class="h-40" :data="ticketsPerWeek" index="week" :categories="['count']" /> <LineChart class="h-40" :data="ticketsPerWeek" index="week" :categories="['count']" />
</TanView> </TanView>
</template> </template>

View File

@@ -30,7 +30,7 @@ const namedTypes = computed(() => {
</script> </script>
<template> <template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="namedTypes"> <TanView :isError="isError" :isPending="isPending" :error="error">
<div v-if="namedTypes" class="flex flex-1 items-center"> <div v-if="namedTypes" class="flex flex-1 items-center">
<DonutChart index="plural" type="donut" category="count" :data="namedTypes" /> <DonutChart index="plural" type="donut" category="count" :data="namedTypes" />
</div> </div>

View File

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

View File

@@ -2,7 +2,16 @@
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import isEqual from 'lodash.isequal'
import { onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import type { JSONSchema } from '@/lib/types' import type { JSONSchema } from '@/lib/types'
@@ -26,6 +35,11 @@ onMounted(() => {
watch( watch(
() => formdata.value, () => formdata.value,
() => { () => {
const normFormdata = JSON.parse(JSON.stringify(formdata.value))
const normModel = JSON.parse(JSON.stringify(model.value))
if (isEqual(normFormdata, normModel)) return
model.value = { ...formdata.value } model.value = { ...formdata.value }
}, },
{ deep: true } { deep: true }
@@ -34,6 +48,26 @@ watch(
<template> <template>
<div v-for="(property, key) in schema.properties" :key="key"> <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 <FormField
v-if="property.type === 'string'" v-if="property.type === 'string'"
:name="key" :name="key"

View File

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

View File

@@ -3,9 +3,6 @@ import ShortCut from '@/components/ShortCut.vue'
import { ref } from 'vue' import { ref } from 'vue'
// import { Textarea } from '@/components/ui/textarea'
// import { Input } from '@/components/ui/input'
const model = defineModel({ const model = defineModel({
type: String type: String
}) })

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex flex-1 items-start justify-start overflow-y-auto overflow-x-hidden">
<slot />
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
defineProps<{
small?: boolean
}>()
</script>
<template>
<div :class="cn('mx-auto flex w-full max-w-[72rem] gap-4 p-4', small && 'max-w-[47rem]')">
<slot />
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
defineProps<{
title?: string
nowrap?: boolean
hideSeparator?: boolean
}>()
</script>
<template>
<div
:class="
cn('flex min-h-14 flex-wrap items-center gap-2 bg-background p-2', nowrap && 'flex-nowrap')
"
>
<h1 v-if="title" class="text-xl font-bold">
{{ title }}
</h1>
<slot />
</div>
<Separator v-if="!hideSeparator" />
</template>

View File

@@ -12,7 +12,7 @@ const props = defineProps<{
<div <div
:class=" :class="
cn( cn(
'flex w-full items-center border-t px-2 py-1 first:rounded-t first:border-none last:rounded-b', 'flex w-full flex-col items-start border-t px-2 py-1 first:rounded-t first:border-none last:rounded-b sm:flex-row sm:items-center',
props.class props.class
) )
" "

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import CatalystLogo from '@/components/common/CatalystLogo.vue'
import IncidentNav from '@/components/sidebar/IncidentNav.vue' import IncidentNav from '@/components/sidebar/IncidentNav.vue'
import NavList from '@/components/sidebar/NavList.vue' import NavList from '@/components/sidebar/NavList.vue'
import UserDropDown from '@/components/sidebar/UserDropDown.vue' import UserDropDown from '@/components/sidebar/UserDropDown.vue'
@@ -7,65 +8,78 @@ import { Separator } from '@/components/ui/separator'
import { Menu } from 'lucide-vue-next' import { Menu } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { useCatalystStore } from '@/store/catalyst' import { useCatalystStore } from '@/store/catalyst'
const catalystStore = useCatalystStore() const catalystStore = useCatalystStore()
</script> </script>
<template> <template>
<div class="flex h-[57px] items-center border-b bg-background"> <div
<img :class="
src="@/assets/flask.svg" cn(
alt="Catalyst" 'flex min-w-48 shrink-0 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
class="h-8 w-8 dark:hidden" catalystStore.sidebarCollapsed && 'min-w-[50px]'
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }" )
/> "
<img
src="@/assets/flask_white.svg"
alt="Catalyst"
class="hidden h-8 w-8 dark:flex"
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }"
/>
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
</div>
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Dashboard',
icon: 'PanelsTopLeft',
variant: 'ghost',
to: '/dashboard'
}
]"
/>
<Separator />
<IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" />
<div class="flex-1" />
<Separator />
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Reactions',
icon: 'Zap',
variant: 'ghost',
to: '/reactions'
}
]"
/>
<Separator />
<UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator />
<Button
variant="ghost"
@click="catalystStore.toggleSidebar()"
size="sm"
class="m-2 justify-start px-3.5"
> >
<Menu class="size-4" /> <div class="flex h-[57px] items-center border-b bg-background">
<span v-if="!catalystStore.sidebarCollapsed" class="ml-2">Toggle Sidebar</span> <CatalystLogo
</Button> class="size-8"
:class="{
'flex-1': catalystStore.sidebarCollapsed,
'mx-3': !catalystStore.sidebarCollapsed
}"
/>
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
</div>
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Dashboard',
icon: 'PanelsTopLeft',
variant: 'ghost',
to: '/dashboard'
}
]"
/>
<Separator />
<IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" />
<div class="flex-1" />
<Separator />
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Reactions',
icon: 'Zap',
variant: 'ghost',
to: '/reactions'
}
]"
/>
<Separator />
<UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator />
<div :class="cn('flex h-14 items-center px-3', !catalystStore.sidebarCollapsed && 'px-2')">
<Button
variant="ghost"
@click="catalystStore.toggleSidebar()"
size="default"
:class="
cn(
'p-0',
catalystStore.sidebarCollapsed && 'w-9',
!catalystStore.sidebarCollapsed && 'w-full justify-start px-3'
)
"
>
<Menu class="size-4" />
<span v-if="!catalystStore.sidebarCollapsed" class="ml-2">Toggle Sidebar</span>
</Button>
</div>
</div>
</template> </template>

View File

@@ -3,29 +3,37 @@ import SideBar from '@/components/layout/SideBar.vue'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useCatalystStore } from '@/store/catalyst'
const catalystStore = useCatalystStore() defineProps<{
showDetails?: boolean
}>()
</script> </script>
<template> <template>
<TooltipProvider :delay-duration="0"> <TooltipProvider :delay-duration="0">
<div class="flex h-full flex-row items-stretch bg-muted/40"> <div class="flex h-full flex-row items-stretch bg-muted/40">
<SideBar />
<div <div
:class=" :class="
cn( cn(
'flex min-w-48 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out', 'w-full flex-initial border-r sm:w-72',
catalystStore.sidebarCollapsed && 'min-w-[50px]' !showDetails && 'flex',
showDetails && 'hidden sm:flex'
) )
" "
> >
<SideBar /> <div class="flex h-full w-full flex-col">
<slot name="list" />
</div>
</div> </div>
<div class="w-72 flex-initial border-r"> <div
<slot name="list" /> :class="
</div> cn('flex-1 overflow-hidden', !showDetails && 'hidden sm:flex', showDetails && 'flex')
<div class="flex-1"> "
<slot name="single" /> >
<div class="flex h-full w-full flex-1 flex-col">
<slot name="single" />
</div>
</div> </div>
</div> </div>
</TooltipProvider> </TooltipProvider>

View File

@@ -1,27 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import SideBar from '@/components/layout/SideBar.vue' import SideBar from '@/components/layout/SideBar.vue'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useCatalystStore } from '@/store/catalyst'
const catalystStore = useCatalystStore()
</script> </script>
<template> <template>
<TooltipProvider :delay-duration="0"> <TooltipProvider :delay-duration="0">
<div class="flex h-full flex-row items-stretch bg-muted/40"> <div class="flex h-full flex-row items-stretch bg-muted/40">
<div <SideBar />
:class=" <div class="flex h-full w-full flex-col">
cn( <slot />
'flex min-w-48 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
catalystStore.sidebarCollapsed && 'min-w-[50px]'
)
"
>
<SideBar />
</div> </div>
<slot />
</div> </div>
</TooltipProvider> </TooltipProvider>
</template> </template>

View File

@@ -1,11 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import TanView from '@/components/TanView.vue' import TanView from '@/components/TanView.vue'
import DeleteDialog from '@/components/common/DeleteDialog.vue' import DeleteDialog from '@/components/common/DeleteDialog.vue'
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import ReactionForm from '@/components/reaction/ReactionForm.vue' import ReactionForm from '@/components/reaction/ReactionForm.vue'
import { ScrollArea } from '@/components/ui/scroll-area' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { ChevronLeft } from 'lucide-vue-next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -72,28 +76,29 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="reaction"> <TanView :isError="isError" :isPending="isPending" :error="error">
<div class="flex h-full flex-1 flex-col overflow-hidden"> <ColumnHeader>
<div class="flex items-center bg-background px-4 py-2"> <Button @click="router.push({ name: 'reactions' })" variant="outline" class="sm:hidden">
<div class="ml-auto"> <ChevronLeft class="mr-2 size-4" />
<DeleteDialog Back
v-if="reaction" </Button>
collection="reactions" <div class="ml-auto">
:id="reaction.id" <DeleteDialog
:name="reaction.name" v-if="reaction"
:singular="'Reaction'" collection="reactions"
:to="{ name: 'reactions' }" :id="reaction.id"
:queryKey="['reactions']" :name="reaction.name"
/> :singular="'Reaction'"
</div> :to="{ name: 'reactions' }"
:queryKey="['reactions']"
/>
</div> </div>
<Separator /> </ColumnHeader>
<ScrollArea v-if="reaction" class="flex-1"> <ColumnBody v-if="reaction">
<div class="flex max-w-[640px] flex-col gap-4 p-4"> <ColumnBodyContainer small>
<ReactionForm :reaction="reaction" @submit="updateReactionMutation.mutate" /> <ReactionForm :reaction="reaction" @submit="updateReactionMutation.mutate" />
</div> </ColumnBodyContainer>
</ScrollArea> </ColumnBody>
</div>
</TanView> </TanView>
</template> </template>

View File

@@ -227,7 +227,7 @@ const curlExample = computed(() => {
let cmd = `curl` let cmd = `curl`
if (values.triggerdata.token) { if (values.triggerdata.token) {
cmd += ` -H "Auth: Bearer ${values.triggerdata.token}"` cmd += ` -H "Authorization: Bearer ${values.triggerdata.token}"`
} }
if (values.triggerdata.path) { if (values.triggerdata.path) {
@@ -239,7 +239,7 @@ const curlExample = computed(() => {
</script> </script>
<template> <template>
<form @submit="onSubmit" class="flex flex-col items-start gap-4"> <form @submit="onSubmit" class="flex w-full flex-col items-start gap-4">
<FormField name="name" v-slot="{ componentField }" validate-on-input> <FormField name="name" v-slot="{ componentField }" validate-on-input>
<FormItem class="w-full"> <FormItem class="w-full">
<FormLabel for="name" class="text-right">Name</FormLabel> <FormLabel for="name" class="text-right">Name</FormLabel>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import TanView from '@/components/TanView.vue' import TanView from '@/components/TanView.vue'
import ResourceListElement from '@/components/common/ResourceListElement.vue' import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import ResourceListElement from '@/components/layout/ResourceListElement.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { useQuery, useQueryClient } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { onMounted } from 'vue' import { onMounted } from 'vue'
@@ -63,32 +63,28 @@ onMounted(() => {
</script> </script>
<template> <template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="reactions"> <TanView :isError="isError" :isPending="isPending" :error="error">
<div class="flex h-screen flex-col"> <ColumnHeader title="Reactions">
<div class="flex items-center bg-background px-4 py-2"> <div class="ml-auto">
<h1 class="text-xl font-bold">Reactions</h1> <Button variant="ghost" @click="openNew">New Reaction</Button>
<div class="ml-auto">
<Button variant="ghost" @click="openNew"> New Reaction</Button>
</div>
</div>
<Separator />
<div class="mt-2 flex flex-1 flex-col gap-2 p-4 pt-0">
<TransitionGroup name="list" appear>
<ResourceListElement
v-for="reaction in reactions"
:key="reaction.id"
:title="reaction.name"
:created="reaction.created"
:subtitle="subtitle(reaction)"
description=""
:active="route.params.id === reaction.id"
:to="{ name: 'reactions', params: { id: reaction.id } }"
:open="false"
>
{{ reaction.name }}
</ResourceListElement>
</TransitionGroup>
</div> </div>
</ColumnHeader>
<div class="mt-2 flex flex-1 flex-col gap-2 p-2 pt-0">
<TransitionGroup name="list" appear>
<ResourceListElement
v-for="reaction in reactions"
:key="reaction.id"
:title="reaction.name"
:created="reaction.created"
:subtitle="subtitle(reaction)"
description=""
:active="route.params.id === reaction.id"
:to="{ name: 'reactions', params: { id: reaction.id } }"
:open="false"
>
{{ reaction.name }}
</ResourceListElement>
</TransitionGroup>
</div> </div>
</TanView> </TanView>
</template> </template>

View File

@@ -1,7 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import ReactionForm from '@/components/reaction/ReactionForm.vue' import ReactionForm from '@/components/reaction/ReactionForm.vue'
import { ScrollArea } from '@/components/ui/scroll-area' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { ChevronLeft } from 'lucide-vue-next'
import { useMutation, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -24,14 +28,16 @@ const addReactionMutation = useMutation({
</script> </script>
<template> <template>
<div class="flex h-full flex-1 flex-col overflow-hidden"> <ColumnHeader>
<div class="flex min-h-14 items-center bg-background px-4 py-2"></div> <Button @click="router.push({ name: 'reactions' })" variant="outline" class="sm:hidden">
<Separator /> <ChevronLeft class="mr-2 size-4" />
Back
</Button>
</ColumnHeader>
<ScrollArea class="flex-1"> <ColumnBody>
<div class="flex max-w-[640px] flex-col gap-4 p-4"> <ColumnBodyContainer small>
<ReactionForm @submit="addReactionMutation.mutate" /> <ReactionForm @submit="addReactionMutation.mutate" />
</div> </ColumnBodyContainer>
</ScrollArea> </ColumnBody>
</div>
</template> </template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Icon from '@/components/Icon.vue' import Icon from '@/components/Icon.vue'
import DeleteDialog from '@/components/common/DeleteDialog.vue' import DeleteDialog from '@/components/common/DeleteDialog.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import TicketCloseDialog from '@/components/ticket/TicketCloseDialog.vue' import TicketCloseDialog from '@/components/ticket/TicketCloseDialog.vue'
import TicketUserSelect from '@/components/ticket/TicketUserSelect.vue' import TicketUserSelect from '@/components/ticket/TicketUserSelect.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -12,7 +13,7 @@ import {
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Check, CircleDot, Repeat } from 'lucide-vue-next' import { Check, ChevronLeft, CircleDot, Repeat } from 'lucide-vue-next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -49,7 +50,7 @@ const changeTypeMutation = useMutation({
}), }),
onSuccess: (data: Ticket) => { onSuccess: (data: Ticket) => {
queryClient.invalidateQueries({ queryKey: ['tickets'] }) queryClient.invalidateQueries({ queryKey: ['tickets'] })
router.push({ name: 'tickets', params: { type: data.type, id: props.ticket.id } }) // router.push({ name: 'tickets', params: { type: data.type, id: props.ticket.id } })
}, },
onError: handleError onError: handleError
}) })
@@ -74,74 +75,81 @@ const closeTicketDialogOpen = ref(false)
</script> </script>
<template> <template>
<div class="flex items-center justify-between bg-background p-2"> <ColumnHeader>
<div class="flex items-center gap-2"> <Button
<Tooltip> @click="router.push({ name: 'tickets', params: { type: ticket.type } })"
<TooltipTrigger as-child> variant="outline"
<div> class="sm:hidden"
<DropdownMenu> >
<DropdownMenuTrigger as-child> <ChevronLeft class="mr-2 size-4" />
<Button variant="outline" :disabled="!ticket"> Back
<Icon :name="ticket.expand.type.icon" class="mr-2 size-4" /> </Button>
{{ ticket.expand.type.singular }} <Tooltip>
</Button> <TooltipTrigger as-child>
</DropdownMenuTrigger> <div>
<DropdownMenuContent> <DropdownMenu>
<DropdownMenuItem <DropdownMenuTrigger as-child>
v-for="type in otherTypes" <Button variant="outline" :disabled="!ticket">
:key="type.id" <Icon :name="ticket.expand.type.icon" class="mr-2 size-4" />
class="cursor-pointer" {{ ticket.expand.type.singular }}
@click="changeTypeMutation.mutate(type.id)" </Button>
> </DropdownMenuTrigger>
<Icon :name="type.icon" class="mr-2 size-4" /> <DropdownMenuContent>
Convert to {{ type.singular }} <DropdownMenuItem
</DropdownMenuItem> v-for="type in otherTypes"
</DropdownMenuContent> :key="type.id"
</DropdownMenu> class="cursor-pointer"
</div> @click="changeTypeMutation.mutate(type.id)"
</TooltipTrigger> >
<TooltipContent>Change Type</TooltipContent> <Icon :name="type.icon" class="mr-2 size-4" />
</Tooltip> Convert to {{ type.singular }}
<TicketCloseDialog v-model="closeTicketDialogOpen" :ticket="ticket" /> </DropdownMenuItem>
<Tooltip> </DropdownMenuContent>
<TooltipTrigger as-child> </DropdownMenu>
<div> </div>
<DropdownMenu> </TooltipTrigger>
<DropdownMenuTrigger as-child> <TooltipContent>Change Type</TooltipContent>
<Button variant="outline" :disabled="!ticket"> </Tooltip>
<CircleDot v-if="ticket.open" class="mr-2 h-4 w-4" /> <TicketCloseDialog v-model="closeTicketDialogOpen" :ticket="ticket" />
<Check v-else class="mr-2 h-4 w-4" /> <Tooltip>
{{ ticket?.open ? 'Open' : 'Closed' }} <TooltipTrigger as-child>
</Button> <div>
</DropdownMenuTrigger> <DropdownMenu>
<DropdownMenuContent> <DropdownMenuTrigger as-child>
<DropdownMenuItem <Button variant="outline" :disabled="!ticket">
v-if="ticket.open" <CircleDot v-if="ticket.open" class="mr-2 h-4 w-4" />
class="cursor-pointer" <Check v-else class="mr-2 h-4 w-4" />
@click="closeTicketDialogOpen = true" {{ ticket?.open ? 'Open' : 'Closed' }}
> </Button>
<Check class="mr-2 size-4" /> </DropdownMenuTrigger>
Close Ticket <DropdownMenuContent>
</DropdownMenuItem> <DropdownMenuItem
<DropdownMenuItem v-else class="cursor-pointer" @click="closeTicketMutation.mutate"> v-if="ticket.open"
<Repeat class="mr-2 size-4" /> class="cursor-pointer"
Reopen Ticket @click="closeTicketDialogOpen = true"
</DropdownMenuItem> >
</DropdownMenuContent> <Check class="mr-2 size-4" />
</DropdownMenu> Close Ticket
</div> </DropdownMenuItem>
</TooltipTrigger> <DropdownMenuItem v-else class="cursor-pointer" @click="closeTicketMutation.mutate">
<TooltipContent>Change Status</TooltipContent> <Repeat class="mr-2 size-4" />
</Tooltip> Reopen Ticket
<Tooltip> </DropdownMenuItem>
<TooltipTrigger as-child> </DropdownMenuContent>
<div> </DropdownMenu>
<TicketUserSelect :key="ticket.owner" :uID="ticket.owner" :ticket="ticket" /> </div>
</div> </TooltipTrigger>
</TooltipTrigger> <TooltipContent>Change Status</TooltipContent>
<TooltipContent>Change User</TooltipContent> </Tooltip>
</Tooltip> <Tooltip>
</div> <TooltipTrigger as-child>
<div>
<TicketUserSelect :key="ticket.owner" :uID="ticket.owner" :ticket="ticket" />
</div>
</TooltipTrigger>
<TooltipContent>Change User</TooltipContent>
</Tooltip>
<div class="-mx-1 flex-1" />
<DeleteDialog <DeleteDialog
v-if="ticket" v-if="ticket"
:collection="'tickets'" :collection="'tickets'"
@@ -151,5 +159,5 @@ const closeTicketDialogOpen = ref(false)
:to="{ name: 'tickets' }" :to="{ name: 'tickets' }"
:queryKey="['tickets']" :queryKey="['tickets']"
/> />
</div> </ColumnHeader>
</template> </template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -38,7 +39,7 @@ const closeButtonDisabled = false // computed(() => !props.ticket.open || messag
</script> </script>
<template> <template>
<div class="flex items-center justify-between gap-2 bg-background p-2"> <ColumnHeader nowrap hideSeparator>
<Input v-if="ticket.open" v-model="resolution" placeholder="Closing reason" /> <Input v-if="ticket.open" v-model="resolution" placeholder="Closing reason" />
<div v-else class="flex-1"> <div v-else class="flex-1">
<p class="ml-2 text-gray-500">Closed: {{ ticket.resolution }}</p> <p class="ml-2 text-gray-500">Closed: {{ ticket.resolution }}</p>
@@ -56,5 +57,5 @@ const closeButtonDisabled = false // computed(() => !props.ticket.open || messag
: 'Reopen ' + props.ticket.expand.type.singular : 'Reopen ' + props.ticket.expand.type.singular
}} }}
</Button> </Button>
</div> </ColumnHeader>
</template> </template>

View File

@@ -2,6 +2,8 @@
import TanView from '@/components/TanView.vue' import TanView from '@/components/TanView.vue'
import JSONSchemaFormFields from '@/components/form/JSONSchemaFormFields.vue' import JSONSchemaFormFields from '@/components/form/JSONSchemaFormFields.vue'
import DynamicMDEditor from '@/components/input/DynamicMDEditor.vue' import DynamicMDEditor from '@/components/input/DynamicMDEditor.vue'
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import StatusIcon from '@/components/ticket/StatusIcon.vue' import StatusIcon from '@/components/ticket/StatusIcon.vue'
import TicketActionBar from '@/components/ticket/TicketActionBar.vue' import TicketActionBar from '@/components/ticket/TicketActionBar.vue'
import TicketCloseBar from '@/components/ticket/TicketCloseBar.vue' import TicketCloseBar from '@/components/ticket/TicketCloseBar.vue'
@@ -15,14 +17,13 @@ import TicketTimeline from '@/components/ticket/timeline/TicketTimeline.vue'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Edit } from 'lucide-vue-next' import { Edit } from 'lucide-vue-next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { pb } from '@/lib/pocketbase' import { pb } from '@/lib/pocketbase'
@@ -100,87 +101,97 @@ const updateDescription = (value: string) => (message.value = value)
</script> </script>
<template> <template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="ticket"> <TanView :isError="isError" :isPending="isPending" :error="error">
<div v-if="ticket" class="flex h-full flex-col"> <template v-if="ticket">
<TicketActionBar :ticket="ticket" /> <TicketActionBar :ticket="ticket" class="shrink-0" />
<Separator /> <ColumnBody>
<div class="flex w-full max-w-7xl flex-1 flex-col overflow-hidden xl:m-auto xl:flex-row"> <ColumnBodyContainer class="flex-col gap-4 xl:flex-row">
<div class="flex flex-1 flex-col gap-4 px-4 pt-4"> <div class="flex flex-1 flex-col gap-4">
<TicketHeader :ticket="ticket" /> <TicketHeader :ticket="ticket" />
<Card class="relative p-4"> <Card class="relative p-4">
<Button v-if="!editMode" variant="outline" class="float-right h-8 gap-2" @click="edit"> <Button
<Edit class="h-3.5 w-3.5" /> v-if="!editMode"
<span>Edit</span> variant="outline"
</Button> class="float-right h-8 gap-2"
<DynamicMDEditor @click="edit"
:modelValue="ticket.description" >
@update:modelValue="updateDescription" <Edit class="h-3.5 w-3.5" />
v-model:edit="editMode" <span>Edit</span>
autofocus </Button>
placeholder="Type a description..." <DynamicMDEditor
@save="editDescriptionMutation.mutate" :modelValue="ticket.description"
class="min-h-14" @update:modelValue="updateDescription"
/> v-model:edit="editMode"
</Card> autofocus
<Separator /> placeholder="Type a description..."
<Tabs default-value="timeline" class="flex flex-1 flex-col overflow-hidden"> @save="editDescriptionMutation.mutate"
<TabsList> class="min-h-14"
<TabsTrigger value="timeline"> />
Timeline </Card>
<Badge <Separator />
v-if=" <Tabs default-value="timeline" class="flex flex-1 flex-col">
ticket.expand.timeline_via_ticket && <TabsList>
ticket.expand.timeline_via_ticket.length > 0 <TabsTrigger value="timeline">
" Timeline
variant="outline" <Badge
class="ml-2" v-if="
> ticket.expand.timeline_via_ticket &&
{{ ticket.expand.timeline_via_ticket.length > 0
ticket.expand.timeline_via_ticket ? ticket.expand.timeline_via_ticket.length : 0 "
}} variant="outline"
</Badge> class="ml-2 hidden sm:inline-flex"
</TabsTrigger> >
<TabsTrigger value="tasks"> {{
Tasks ticket.expand.timeline_via_ticket
<Badge ? ticket.expand.timeline_via_ticket.length
v-if="ticket.expand.tasks_via_ticket && ticket.expand.tasks_via_ticket.length > 0" : 0
variant="outline" }}
class="ml-2" </Badge>
> </TabsTrigger>
{{ ticket.expand.tasks_via_ticket ? ticket.expand.tasks_via_ticket.length : 0 }} <TabsTrigger value="tasks">
<StatusIcon :status="taskStatus" class="size-6" /> Tasks
</Badge> <Badge
</TabsTrigger> v-if="
<TabsTrigger value="comments"> ticket.expand.tasks_via_ticket && ticket.expand.tasks_via_ticket.length > 0
Comments "
<Badge variant="outline"
v-if=" class="ml-2 hidden sm:inline-flex"
ticket.expand.comments_via_ticket && >
ticket.expand.comments_via_ticket.length > 0 {{ ticket.expand.tasks_via_ticket ? ticket.expand.tasks_via_ticket.length : 0 }}
" <StatusIcon :status="taskStatus" class="size-6" />
variant="outline" </Badge>
class="ml-2" </TabsTrigger>
> <TabsTrigger value="comments">
{{ Comments
ticket.expand.comments_via_ticket ? ticket.expand.comments_via_ticket.length : 0 <Badge
}} v-if="
</Badge> ticket.expand.comments_via_ticket &&
</TabsTrigger> ticket.expand.comments_via_ticket.length > 0
</TabsList> "
<TicketTab value="timeline"> variant="outline"
<TicketTimeline :ticket="ticket" :timeline="ticket.expand.timeline_via_ticket" /> class="ml-2 hidden sm:inline-flex"
</TicketTab> >
<TicketTab value="tasks"> {{
<TicketTasks :ticket="ticket" :tasks="ticket.expand.tasks_via_ticket" /> ticket.expand.comments_via_ticket
</TicketTab> ? ticket.expand.comments_via_ticket.length
<TicketTab value="comments"> : 0
<TicketComments :ticket="ticket" :comments="ticket.expand.comments_via_ticket" /> }}
</TicketTab> </Badge>
</Tabs> </TabsTrigger>
<Separator class="xl:hidden" /> </TabsList>
</div> <TicketTab value="timeline">
<ScrollArea> <TicketTimeline :ticket="ticket" :timeline="ticket.expand.timeline_via_ticket" />
<div class="flex flex-initial flex-col gap-4 p-4 xl:w-96"> </TicketTab>
<TicketTab value="tasks">
<TicketTasks :ticket="ticket" :tasks="ticket.expand.tasks_via_ticket" />
</TicketTab>
<TicketTab value="comments">
<TicketComments :ticket="ticket" :comments="ticket.expand.comments_via_ticket" />
</TicketTab>
</Tabs>
<Separator class="xl:hidden" />
</div>
<div class="flex flex-col gap-4 xl:w-96 xl:flex-initial">
<div> <div>
<div class="flex h-10 flex-row items-center justify-between text-muted-foreground"> <div class="flex h-10 flex-row items-center justify-between text-muted-foreground">
<span class="text-sm font-semibold"> Details </span> <span class="text-sm font-semibold"> Details </span>
@@ -196,10 +207,10 @@ const updateDescription = (value: string) => (message.value = value)
<Separator /> <Separator />
<TicketFiles :ticket="ticket" :files="ticket.expand.files_via_ticket" /> <TicketFiles :ticket="ticket" :files="ticket.expand.files_via_ticket" />
</div> </div>
</ScrollArea> </ColumnBodyContainer>
</div> </ColumnBody>
<Separator /> <Separator />
<TicketCloseBar :ticket="ticket" /> <TicketCloseBar :ticket="ticket" class="shrink-0" />
</div> </template>
</TanView> </TanView>
</template> </template>

View File

@@ -38,13 +38,13 @@ const updateName = (value: string) => {
<DynamicInput :modelValue="ticket.name" @update:modelValue="updateName" class="-mx-1" /> <DynamicInput :modelValue="ticket.name" @update:modelValue="updateName" class="-mx-1" />
</span> </span>
<div class="flex flex-row space-x-2 px-1 text-xs"> <div class="flex flex-col items-stretch gap-1 text-xs text-muted-foreground md:h-4 md:flex-row">
<div class="flex items-center gap-1 text-muted-foreground"> <div>
Created: Created:
{{ format(new Date(ticket.created), 'PPpp') }} {{ format(new Date(ticket.created), 'PPpp') }}
</div> </div>
<Separator orientation="vertical" /> <Separator orientation="vertical" class="hidden md:block" />
<div class="flex items-center gap-1 text-muted-foreground"> <div>
Updated: Updated:
{{ format(new Date(ticket.updated), 'PPpp') }} {{ format(new Date(ticket.updated), 'PPpp') }}
</div> </div>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import TicketListList from '@/components/ticket/TicketListList.vue' import TicketListList from '@/components/ticket/TicketListList.vue'
import TicketNewDialog from '@/components/ticket/TicketNewDialog.vue' import TicketNewDialog from '@/components/ticket/TicketNewDialog.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@@ -14,12 +15,10 @@ import {
PaginationNext, PaginationNext,
PaginationPrev PaginationPrev
} from '@/components/ui/pagination' } from '@/components/ui/pagination'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' 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 { useQuery } from '@tanstack/vue-query'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
@@ -28,7 +27,7 @@ import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { pb } from '@/lib/pocketbase' import { pb } from '@/lib/pocketbase'
import type { Ticket, Type } from '@/lib/types' import type { SearchTicket, Type } from '@/lib/types'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -48,15 +47,13 @@ const filter = computed(() => {
let raws: Array<string> = [ let raws: Array<string> = [
'name ~ {:search}', 'name ~ {:search}',
'description ~ {:search}', 'description ~ {:search}',
'owner.name ~ {:search}', 'owner_name ~ {:search}',
'owner.email ~ {:search}', 'comment_messages ~ {:search}',
'links_via_ticket.name ~ {:search}', 'file_names ~ {:search}',
'links_via_ticket.url ~ {:search}', 'link_names ~ {:search}',
'tasks_via_ticket.name ~ {:search}', 'link_urls ~ {:search}',
'comments_via_ticket.message ~ {:search}', 'task_names ~ {:search}',
'files_via_ticket.name ~ {:search}', 'timeline_messages ~ {:search}'
'timeline_via_ticket.message ~ {:search}',
'state.severity ~ {:search}'
] ]
Object.keys(props.selectedType.schema.properties).forEach((key) => { Object.keys(props.selectedType.schema.properties).forEach((key) => {
@@ -96,12 +93,10 @@ const {
refetch refetch
} = useQuery({ } = useQuery({
queryKey: ['tickets', filter.value], queryKey: ['tickets', filter.value],
queryFn: (): Promise<ListResult<Ticket>> => queryFn: (): Promise<ListResult<SearchTicket>> =>
pb.collection('tickets').getList(page.value, perPage.value, { pb.collection('ticket_search').getList(page.value, perPage.value, {
sort: '-created', sort: '-created',
filter: filter.value, filter: filter.value
expand:
'type,owner,comments_via_ticket.author,files_via_ticket,timeline_via_ticket,links_via_ticket,tasks_via_ticket.owner'
}) })
}) })
@@ -111,7 +106,7 @@ watch(
if (!route.params.id && ticketItems.value && ticketItems.value.items.length > 0) { if (!route.params.id && ticketItems.value && ticketItems.value.items.length > 0) {
router.push({ router.push({
name: 'tickets', name: 'tickets',
params: { type: props.selectedType.id, id: ticketItems.value.items[0].id } params: { type: props.selectedType.id }
}) })
} }
} }
@@ -126,108 +121,81 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
</script> </script>
<template> <template>
<div class="flex h-screen flex-col"> <ColumnHeader :title="selectedType?.plural">
<div class="flex items-center bg-background px-4 py-2"> <div class="ml-auto">
<h1 class="text-xl font-bold"> <TicketNewDialog :selectedType="selectedType" />
{{ selectedType?.plural }} </div>
</h1> </ColumnHeader>
<div class="ml-auto"> <Tabs v-model="tab" class="flex flex-1 flex-col overflow-hidden">
<TicketNewDialog :selectedType="selectedType" /> <div class="flex items-center justify-between px-2 pt-2">
</div> <TabsList>
<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" />
<span class="sr-only sm:not-sr-only">Filter</span>
</Button-->
</div>
<div class="p-2">
<form>
<div class="relative flex flex-row items-center">
<Input v-model="searchValue" placeholder="Search" @keydown.enter.prevent class="pl-8" />
<span class="absolute inset-y-0 start-0 flex items-center justify-center px-2">
<Search class="size-4 text-muted-foreground" />
</span>
</div>
</form>
</div> </div>
<Separator /> <Separator />
<Tabs v-model="tab" class="flex flex-1 flex-col overflow-hidden"> <div v-if="isPending" class="flex h-full w-full items-center justify-center">
<div class="flex items-center justify-between px-4 pt-2"> <LoaderCircle class="h-16 w-16 animate-spin text-primary" />
<TabsList> </div>
<TabsTrigger value="all"> All</TabsTrigger> <Alert v-else-if="isError" variant="destructive" class="mb-2 h-screen w-screen">
<TabsTrigger value="open"> Open</TabsTrigger> <AlertTitle>Error</AlertTitle>
<TabsTrigger value="closed"> Closed</TabsTrigger> <AlertDescription>{{ error }}</AlertDescription>
</TabsList> </Alert>
<!-- Button variant="outline" size="sm" class="h-7 gap-1 rounded-md px-3"> <div v-else-if="ticketItems" class="flex-1 overflow-y-auto overflow-x-hidden">
<ListFilter class="h-3.5 w-3.5" /> <TicketListList :tickets="ticketItems.items" />
<span class="sr-only sm:not-sr-only">Filter</span> </div>
</Button--> <Separator />
</div> <div class="my-2 flex items-center justify-center">
<div class="px-4 py-2"> <span class="text-xs text-muted-foreground">
<form> {{ ticketItems ? ticketItems.items.length : '?' }} of
<div class="relative flex flex-row items-center"> {{ ticketItems ? ticketItems.totalItems : '?' }} tickets
<Input v-model="searchValue" placeholder="Search" @keydown.enter.prevent class="pl-8" /> </span>
<span class="absolute inset-y-0 start-0 flex items-center justify-center px-2"> </div>
<Search class="size-4 text-muted-foreground" /> <div class="mb-2 flex items-center justify-center">
</span> <Pagination
v-slot="{ page }"
:total="ticketItems ? ticketItems.totalItems : 0"
:itemsPerPage="perPage"
:sibling-count="0"
:default-page="1"
@update:page="page = $event"
>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst />
<PaginationPrev />
<div> <template v-for="(item, index) in items">
<TooltipProvider :delay-duration="0"> <PaginationListItem
<Tooltip> v-if="item.type === 'page'"
<TooltipTrigger as-child> :key="index"
<Info class="ml-2 size-4 text-muted-foreground" /> :value="item.value"
</TooltipTrigger> as-child
<TooltipContent> >
<p class="w-64"> <Button class="h-10 w-10 p-0" :variant="item.value === page ? 'default' : 'outline'">
Search name, description, or owner. Links, tasks, comments, files, and {{ item.value }}
timeline messages are also searched, but cause unreliable results if there are </Button>
more than 1000 records. </PaginationListItem>
</p> <PaginationEllipsis v-else :key="item.type" :index="index" />
</TooltipContent> </template>
</Tooltip> <PaginationNext />
</TooltipProvider> <PaginationLast />
</div> </PaginationList>
</div> </Pagination>
</form> </div>
</div> </Tabs>
<Separator />
<div v-if="isPending" class="flex h-full w-full items-center justify-center">
<LoaderCircle class="h-16 w-16 animate-spin text-primary" />
</div>
<Alert v-else-if="isError" variant="destructive" class="mb-4 h-screen w-screen">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<ScrollArea v-else-if="ticketItems" class="flex-1">
<TicketListList :tickets="ticketItems.items" />
</ScrollArea>
<Separator />
<div class="my-2 flex items-center justify-center">
<span class="text-xs text-muted-foreground">
{{ ticketItems ? ticketItems.items.length : '?' }} of
{{ ticketItems ? ticketItems.totalItems : '?' }} tickets
</span>
</div>
<div class="mb-4 flex items-center justify-center">
<Pagination
v-slot="{ page }"
:total="ticketItems ? ticketItems.totalItems : 0"
:itemsPerPage="perPage"
:sibling-count="0"
:default-page="1"
@update:page="page = $event"
>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst />
<PaginationPrev />
<template v-for="(item, index) in items">
<PaginationListItem
v-if="item.type === 'page'"
:key="index"
:value="item.value"
as-child
>
<Button
class="h-10 w-10 p-0"
:variant="item.value === page ? 'default' : 'outline'"
>
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :key="item.type" :index="index" />
</template>
<PaginationNext />
<PaginationLast />
</PaginationList>
</Pagination>
</div>
</Tabs>
</div>
</template> </template>

View File

@@ -1,28 +1,28 @@
<script lang="ts" setup> <script lang="ts" setup>
import ResourceListElement from '@/components/common/ResourceListElement.vue' import ResourceListElement from '@/components/layout/ResourceListElement.vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import type { Ticket } from '@/lib/types' import type { SearchTicket } from '@/lib/types'
const route = useRoute() const route = useRoute()
defineProps<{ defineProps<{
tickets: Array<Ticket> tickets: Array<SearchTicket>
}>() }>()
</script> </script>
<template> <template>
<div class="mt-2 flex w-full flex-1 flex-col gap-2 p-4 pt-0"> <div class="mt-2 flex w-full flex-1 flex-col gap-2 p-2 pt-0">
<ResourceListElement <ResourceListElement
v-for="item of tickets" v-for="item of tickets"
:key="item.id" :key="item.id"
:title="item.name" :title="item.name"
:created="item.created" :created="item.created"
:subtitle="item.expand.owner ? item.expand.owner.name : ''" :subtitle="item.owner_name"
:description="item.description ? item.description.substring(0, 300) : ''" :description="item.description ? item.description.substring(0, 300) : ''"
:active="route.params.id === item.id" :active="route.params.id === item.id"
:to="`/tickets/${item.expand.type.id}/${item.id}`" :to="`/tickets/${item.type}/${item.id}`"
:open="item.open" :open="item.open"
/> />
</div> </div>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { TabsContent } from '@/components/ui/tabs' import { TabsContent } from '@/components/ui/tabs'
@@ -12,10 +11,8 @@ defineProps<{
<TabsContent :value="value" class="flex-1 overflow-hidden"> <TabsContent :value="value" class="flex-1 overflow-hidden">
<div class="flex h-full flex-col overflow-hidden"> <div class="flex h-full flex-col overflow-hidden">
<Separator class="mt-2" /> <Separator class="mt-2" />
<ScrollArea class="flex-1"> <slot />
<slot /> <div class="h-4" />
<div class="h-4" />
</ScrollArea>
</div> </div>
</TabsContent> </TabsContent>
</template> </template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import DeleteDialog from '@/components/common/DeleteDialog.vue' import DeleteDialog from '@/components/common/DeleteDialog.vue'
import PanelListElement from '@/components/common/PanelListElement.vue' import PanelListElement from '@/components/layout/PanelListElement.vue'
import TicketPanel from '@/components/ticket/TicketPanel.vue' import TicketPanel from '@/components/ticket/TicketPanel.vue'
import LinkAddDialog from '@/components/ticket/link/LinkAddDialog.vue' import LinkAddDialog from '@/components/ticket/link/LinkAddDialog.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -28,7 +28,12 @@ const dialogOpen = ref(false)
> >
No links added yet. No links added yet.
</div> </div>
<PanelListElement v-for="link in links" :key="link.id" :title="link.url" class="pr-1"> <PanelListElement
v-for="link in links"
:key="link.id"
:title="link.url"
class="flex-row items-center pr-1"
>
<a :href="link.url" target="_blank" class="flex flex-1 items-center overflow-hidden"> <a :href="link.url" target="_blank" class="flex flex-1 items-center overflow-hidden">
<span class="mr-2 text-blue-500 underline"> <span class="mr-2 text-blue-500 underline">
{{ link.name }} {{ link.name }}

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import DeleteDialog from '@/components/common/DeleteDialog.vue' import DeleteDialog from '@/components/common/DeleteDialog.vue'
import PanelListElement from '@/components/common/PanelListElement.vue'
import UserSelect from '@/components/common/UserSelect.vue' import UserSelect from '@/components/common/UserSelect.vue'
import DynamicInput from '@/components/input/DynamicInput.vue' import DynamicInput from '@/components/input/DynamicInput.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import TaskAddDialog from '@/components/ticket/task/TaskAddDialog.vue' import TaskAddDialog from '@/components/ticket/task/TaskAddDialog.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card' import { Card } from '@/components/ui/card'
@@ -67,12 +67,14 @@ const updateTaskName = (id: string, name: string) => updateTaskNameMutation.muta
</Card> </Card>
<Card v-else> <Card v-else>
<PanelListElement v-for="task in tasks" :key="task.id" class="pr-1"> <PanelListElement v-for="task in tasks" :key="task.id" class="pr-1">
<Checkbox :checked="!task.open" class="mr-2" @click="check(task)" /> <div class="flex flex-row items-center">
<DynamicInput <Checkbox :checked="!task.open" class="mr-2" @click="check(task)" />
:modelValue="task.name" <DynamicInput
@update:modelValue="updateTaskName(task.id, $event)" :modelValue="task.name"
class="mr-2 flex-1" @update:modelValue="updateTaskName(task.id, $event)"
/> class="mr-2 flex-1"
/>
</div>
<div class="ml-auto flex items-center"> <div class="ml-auto flex items-center">
<UserSelect v-if="!task.expand.owner" @update:modelValue="update(task.id, $event)"> <UserSelect v-if="!task.expand.owner" @update:modelValue="update(task.id, $event)">
<Button variant="outline" role="combobox" class="h-8"> <Button variant="outline" role="combobox" class="h-8">

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import PanelListElement from '@/components/common/PanelListElement.vue'
import DynamicMDEditor from '@/components/input/DynamicMDEditor.vue' import DynamicMDEditor from '@/components/input/DynamicMDEditor.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import {
AccordionRoot,
type AccordionRootEmits,
type AccordionRootProps,
useForwardPropsEmits
} from 'radix-vue'
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot />
</AccordionRoot>
</template>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import { AccordionContent, type AccordionContentProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AccordionContent
v-bind="delegatedProps"
class="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<div :class="cn('pb-4 pt-0', props.class)">
<slot />
</div>
</AccordionContent>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
<slot />
</AccordionItem>
</template>

View File

@@ -1,33 +0,0 @@
<script setup lang="ts">
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class
)
"
>
<slot />
<!-- slot name="icon">
<ChevronDown class="h-4 w-4 shrink-0 transition-transform duration-200" />
</slot-->
</AccordionTrigger>
</AccordionHeader>
</template>

View File

@@ -1,4 +0,0 @@
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import { type AvatarVariants, avatarVariant } from '.'
import { AvatarRoot } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
size?: AvatarVariants['size']
shape?: AvatarVariants['shape']
}>(),
{
size: 'sm',
shape: 'circle'
}
)
</script>
<template>
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
<slot />
</AvatarRoot>
</template>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
import { AvatarFallback, type AvatarFallbackProps } from 'radix-vue'
const props = defineProps<AvatarFallbackProps>()
</script>
<template>
<AvatarFallback v-bind="props">
<slot />
</AvatarFallback>
</template>

View File

@@ -1,9 +0,0 @@
<script setup lang="ts">
import { AvatarImage, type AvatarImageProps } from 'radix-vue'
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage v-bind="props" class="h-full w-full object-cover" />
</template>

View File

@@ -1,24 +0,0 @@
import { type VariantProps, cva } from 'class-variance-authority'
export { default as Avatar } from './Avatar.vue'
export { default as AvatarImage } from './AvatarImage.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'
export const avatarVariant = cva(
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
{
variants: {
size: {
sm: 'h-10 w-10 text-xs',
base: 'h-16 w-16 text-2xl',
lg: 'h-32 w-32 text-5xl'
},
shape: {
circle: 'rounded-full',
square: 'rounded-md'
}
}
}
)
export type AvatarVariants = VariantProps<typeof avatarVariant>

View File

@@ -1,69 +0,0 @@
<script lang="ts" setup>
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNextButton,
CalendarPrevButton
} from '.'
import {
CalendarRoot,
type CalendarRootEmits,
type CalendarRootProps,
useForwardPropsEmits
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CalendarRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CalendarRoot v-slot="{ grid, weekDays }" :class="cn('p-3', props.class)" v-bind="forwarded">
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="mt-4 flex flex-col gap-y-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell v-for="day in weekDays" :key="day">
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow
v-for="(weekDates, index) in month.rows"
:key="`weekDate-${index}`"
class="mt-2 w-full"
>
<CalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
<CalendarCellTrigger :day="weekDate" :month="month.value" />
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>

View File

@@ -1,30 +0,0 @@
<script lang="ts" setup>
import { CalendarCell, type CalendarCellProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCell
:class="
cn(
'relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-month])]:bg-accent/50',
props.class
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>

View File

@@ -1,42 +0,0 @@
<script lang="ts" setup>
import { buttonVariants } from '@/components/ui/button'
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
:class="
cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-month]:pointer-events-none data-[outside-month]:text-muted-foreground data-[outside-month]:opacity-50 [&[data-outside-month][data-selected]]:bg-accent/50 [&[data-outside-month][data-selected]]:text-muted-foreground [&[data-outside-month][data-selected]]:opacity-30',
props.class
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>

View File

@@ -1,25 +0,0 @@
<script lang="ts" setup>
import { CalendarGrid, type CalendarGridProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>

View File

@@ -1,11 +0,0 @@
<script lang="ts" setup>
import { CalendarGridBody, type CalendarGridBodyProps } from 'radix-vue'
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody v-bind="props">
<slot />
</CalendarGridBody>
</template>

View File

@@ -1,11 +0,0 @@
<script lang="ts" setup>
import { CalendarGridHead, type CalendarGridHeadProps } from 'radix-vue'
const props = defineProps<CalendarGridHeadProps>()
</script>
<template>
<CalendarGridHead v-bind="props">
<slot />
</CalendarGridHead>
</template>

View File

@@ -1,22 +0,0 @@
<script lang="ts" setup>
import { CalendarGridRow, type CalendarGridRowProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
<slot />
</CalendarGridRow>
</template>

View File

@@ -1,25 +0,0 @@
<script lang="ts" setup>
import { CalendarHeadCell, type CalendarHeadCellProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeadCell
:class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarHeadCell>
</template>

View File

@@ -1,25 +0,0 @@
<script lang="ts" setup>
import { CalendarHeader, type CalendarHeaderProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeader
:class="cn('relative flex w-full items-center justify-between pt-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarHeader>
</template>

View File

@@ -1,28 +0,0 @@
<script lang="ts" setup>
import { CalendarHeading, type CalendarHeadingProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeading
v-slot="{ headingValue }"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</CalendarHeading>
</template>

View File

@@ -1,37 +0,0 @@
<script lang="ts" setup>
import { buttonVariants } from '@/components/ui/button'
import { ChevronRight } from 'lucide-vue-next'
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarNext
:class="
cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class
)
"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</CalendarNext>
</template>

View File

@@ -1,37 +0,0 @@
<script lang="ts" setup>
import { buttonVariants } from '@/components/ui/button'
import { ChevronLeft } from 'lucide-vue-next'
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarPrev
:class="
cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class
)
"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</CalendarPrev>
</template>

View File

@@ -1,12 +0,0 @@
export { default as Calendar } from './Calendar.vue'
export { default as CalendarCell } from './CalendarCell.vue'
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue'
export { default as CalendarGrid } from './CalendarGrid.vue'
export { default as CalendarGridBody } from './CalendarGridBody.vue'
export { default as CalendarGridHead } from './CalendarGridHead.vue'
export { default as CalendarGridRow } from './CalendarGridRow.vue'
export { default as CalendarHeadCell } from './CalendarHeadCell.vue'
export { default as CalendarHeader } from './CalendarHeader.vue'
export { default as CalendarHeading } from './CalendarHeading.vue'
export { default as CalendarNextButton } from './CalendarNextButton.vue'
export { default as CalendarPrevButton } from './CalendarPrevButton.vue'

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import { CollapsibleRoot, useForwardPropsEmits } from 'radix-vue'
import type { CollapsibleRootEmits, CollapsibleRootProps } from 'radix-vue'
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<CollapsibleRoot v-slot="{ open }" v-bind="forwarded">
<slot :open="open" />
</CollapsibleRoot>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import { CollapsibleContent, type CollapsibleContentProps } from 'radix-vue'
const props = defineProps<CollapsibleContentProps>()
</script>
<template>
<CollapsibleContent
v-bind="props"
class="overflow-hidden transition-all data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
>
<slot />
</CollapsibleContent>
</template>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
import { CollapsibleTrigger, type CollapsibleTriggerProps } from 'radix-vue'
const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger v-bind="props">
<slot />
</CollapsibleTrigger>
</template>

View File

@@ -1,3 +0,0 @@
export { default as Collapsible } from './Collapsible.vue'
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue'
export { default as CollapsibleContent } from './CollapsibleContent.vue'

View File

@@ -1,43 +0,0 @@
<script setup lang="ts">
import { GripVertical } from 'lucide-vue-next'
import {
SplitterResizeHandle,
type SplitterResizeHandleEmits,
type SplitterResizeHandleProps,
useForwardPropsEmits
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<
SplitterResizeHandleProps & { class?: HTMLAttributes['class']; withHandle?: boolean }
>()
const emits = defineEmits<SplitterResizeHandleEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SplitterResizeHandle
v-bind="forwarded"
:class="
cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-orientation=vertical]>div]:rotate-90 [&[data-orientation=vertical]]:h-px [&[data-orientation=vertical]]:w-full [&[data-orientation=vertical]]:after:left-0 [&[data-orientation=vertical]]:after:h-1 [&[data-orientation=vertical]]:after:w-full [&[data-orientation=vertical]]:after:-translate-y-1/2 [&[data-orientation=vertical]]:after:translate-x-0',
props.class
)
"
>
<template v-if="props.withHandle">
<div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical class="h-2.5 w-2.5" />
</div>
</template>
</SplitterResizeHandle>
</template>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import {
SplitterGroup,
type SplitterGroupEmits,
type SplitterGroupProps,
useForwardPropsEmits
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<SplitterGroupProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<SplitterGroupEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SplitterGroup
v-bind="forwarded"
:class="cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', props.class)"
>
<slot />
</SplitterGroup>
</template>

View File

@@ -1,3 +0,0 @@
export { default as ResizablePanelGroup } from './ResizablePanelGroup.vue'
export { default as ResizableHandle } from './ResizableHandle.vue'
export { SplitterPanel as ResizablePanel } from 'radix-vue'

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import ScrollBar from './ScrollBar.vue'
import {
ScrollAreaCorner,
ScrollAreaRoot,
type ScrollAreaRootProps,
ScrollAreaViewport
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
import { ScrollAreaScrollbar, type ScrollAreaScrollbarProps, ScrollAreaThumb } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(),
{
orientation: 'vertical'
}
)
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaScrollbar
v-bind="delegatedProps"
:class="
cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
props.class
)
"
>
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbar>
</template>

View File

@@ -1,2 +0,0 @@
export { default as ScrollArea } from './ScrollArea.vue'
export { default as ScrollBar } from './ScrollBar.vue'

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import {
SwitchRoot,
type SwitchRootEmits,
type SwitchRootProps,
SwitchThumb,
useForwardPropsEmits
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
v-bind="forwarded"
:class="
cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
props.class
)
"
>
<SwitchThumb
:class="
cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)
"
/>
</SwitchRoot>
</template>

View File

@@ -1 +0,0 @@
export { default as Switch } from './Switch.vue'

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 { export interface Task {
id: string id: string
@@ -126,6 +136,7 @@ export interface JSONSchema {
title: string title: string
type: string type: string
description?: string description?: string
enum?: Array<string>
} }
> >
required?: Array<string> required?: Array<string>

View File

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

View File

@@ -3,10 +3,11 @@ import OpenTasks from '@/components/dashboard/OpenTasks.vue'
import OpenTickets from '@/components/dashboard/OpenTickets.vue' import OpenTickets from '@/components/dashboard/OpenTickets.vue'
import TicketOverTime from '@/components/dashboard/TicketOverTime.vue' import TicketOverTime from '@/components/dashboard/TicketOverTime.vue'
import TicketTypes from '@/components/dashboard/TicketTypes.vue' import TicketTypes from '@/components/dashboard/TicketTypes.vue'
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import TwoColumn from '@/components/layout/TwoColumn.vue' import TwoColumn from '@/components/layout/TwoColumn.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 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 { ExternalLink } from 'lucide-vue-next'
@@ -46,96 +47,91 @@ onMounted(() => {
<template> <template>
<TwoColumn> <TwoColumn>
<div class="flex h-screen flex-1 flex-col"> <ColumnHeader title="Dashboard" />
<div class="flex h-14 min-h-14 items-center bg-background px-4 py-2"> <ColumnBody>
<h1 class="text-xl font-bold">Dashboard</h1> <ColumnBodyContainer
</div> class="grid grid-cols-1 grid-rows-[100px_100px_100px_100px] md:grid-cols-2 md:grid-rows-[100px_100px] xl:grid-cols-4 xl:grid-rows-[100px]"
<Separator class="shrink-0" /> >
<ScrollArea> <Card>
<div <CardHeader>
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]" <CardTitle>{{ count('tasks') }}</CardTitle>
> <CardDescription>Tasks</CardDescription>
<Card> </CardHeader>
<CardHeader> </Card>
<CardTitle>{{ count('tasks') }}</CardTitle> <Card>
<CardDescription>Tasks</CardDescription> <CardHeader>
</CardHeader> <CardTitle>{{ count('tickets') }}</CardTitle>
</Card> <CardDescription>Tickets</CardDescription>
<Card> </CardHeader>
<CardHeader> </Card>
<CardTitle>{{ count('tickets') }}</CardTitle> <Card>
<CardDescription>Tickets</CardDescription> <CardHeader>
</CardHeader> <CardTitle>{{ count('users') }}</CardTitle>
</Card> <CardDescription>Users</CardDescription>
<Card> </CardHeader>
<CardHeader> </Card>
<CardTitle>{{ count('users') }}</CardTitle> <Card>
<CardDescription>Users</CardDescription> <CardHeader>
</CardHeader> <CardTitle>{{ count('reactions') }}</CardTitle>
</Card> <CardDescription>Reactions</CardDescription>
<Card> </CardHeader>
<CardHeader> </Card>
<CardTitle></CardTitle> <Card>
<CardDescription></CardDescription> <CardHeader>
</CardHeader> <CardTitle> Catalyst</CardTitle>
</Card> </CardHeader>
<Card> <CardContent class="flex flex-1 flex-col gap-1">
<CardHeader> <a
<CardTitle> Catalyst</CardTitle> href="https://catalyst.security-brewery.com/docs/category/catalyst-handbook"
</CardHeader> target="_blank"
<CardContent class="flex flex-1 flex-col gap-1"> class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
<a >
href="https://catalyst-soar.com/docs/category/catalyst-handbook" Open Catalyst Handbook
target="_blank" <ExternalLink class="ml-2 h-4 w-4" />
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent" </a>
> <a
Open Catalyst Handbook href="/_/"
<ExternalLink class="ml-2 h-4 w-4" /> target="_blank"
</a> class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
<a >
href="/_/" Open Admin Interface
target="_blank" <ExternalLink class="ml-2 h-4 w-4" />
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent" </a>
> </CardContent>
Open Admin Interface </Card>
<ExternalLink class="ml-2 h-4 w-4" /> <Card>
</a> <CardHeader>
</CardContent> <CardTitle> Tickets by Type</CardTitle>
</Card> </CardHeader>
<Card> <CardContent>
<CardHeader> <TicketTypes />
<CardTitle> Tickets by Type</CardTitle> </CardContent>
</CardHeader> </Card>
<CardContent> <Card class="xl:col-span-2">
<TicketTypes /> <CardHeader>
</CardContent> <CardTitle>Tickets Per Week</CardTitle>
</Card> </CardHeader>
<Card class="xl:col-span-2"> <CardContent>
<CardHeader> <TicketOverTime />
<CardTitle>Tickets Per Week</CardTitle> </CardContent>
</CardHeader> </Card>
<CardContent> <Card class="xl:col-span-2">
<TicketOverTime /> <CardHeader>
</CardContent> <CardTitle>Your Open Tickets</CardTitle>
</Card> </CardHeader>
<Card class="xl:col-span-2"> <CardContent>
<CardHeader> <OpenTickets />
<CardTitle>Your Open Tickets</CardTitle> </CardContent>
</CardHeader> </Card>
<CardContent> <Card class="xl:col-span-2">
<OpenTickets /> <CardHeader>
</CardContent> <CardTitle>Your Open Tasks</CardTitle>
</Card> </CardHeader>
<Card class="xl:col-span-2"> <CardContent>
<CardHeader> <OpenTasks />
<CardTitle>Your Open Tasks</CardTitle> </CardContent>
</CardHeader> </Card>
<CardContent> </ColumnBodyContainer>
<OpenTasks /> </ColumnBody>
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
</TwoColumn> </TwoColumn>
</template> </template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import CatalystLogo from '@/components/common/CatalystLogo.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -8,9 +9,11 @@ import { useQuery } from '@tanstack/vue-query'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { pb } from '@/lib/pocketbase' import { pb } from '@/lib/pocketbase'
import { cn } from '@/lib/utils'
const mail = ref('') const mail = ref('')
const password = ref('') const password = ref('')
const errorTitle = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const login = () => { const login = () => {
@@ -20,6 +23,7 @@ const login = () => {
window.location.href = '/ui/' window.location.href = '/ui/'
}) })
.catch((error) => { .catch((error) => {
errorTitle.value = 'Login failed'
errorMessage.value = error.message errorMessage.value = error.message
}) })
} }
@@ -37,7 +41,8 @@ watch(
mail.value = 'user@catalyst-soar.com' mail.value = 'user@catalyst-soar.com'
password.value = '1234567890' password.value = '1234567890'
} }
} },
{ immediate: true }
) )
</script> </script>
@@ -45,12 +50,18 @@ watch(
<div class="flex h-full w-full flex-1 items-center justify-center"> <div class="flex h-full w-full flex-1 items-center justify-center">
<Card class="m-auto w-96"> <Card class="m-auto w-96">
<CardHeader class="flex flex-row justify-between"> <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> </CardHeader>
<CardContent class="flex flex-col gap-4"> <CardContent class="flex flex-col gap-4">
<Alert v-if="errorMessage" variant="destructive" class="border-4 p-4"> <Alert v-if="errorTitle || errorMessage" variant="destructive" class="border-4 p-4">
<AlertTitle>Error</AlertTitle> <AlertTitle v-if="errorTitle">{{ errorTitle }}</AlertTitle>
<AlertDescription>{{ errorMessage }}</AlertDescription> <AlertDescription v-if="errorMessage">{{ errorMessage }}</AlertDescription>
</Alert> </Alert>
<Input <Input
v-model="mail" v-model="mail"
@@ -66,7 +77,14 @@ watch(
class="w-full" class="w-full"
@keydown.enter="login" @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> </CardContent>
</Card> </Card>
</div> </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>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts" xmlns="http://www.w3.org/1999/html"> <script setup lang="ts">
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ThreeColumn from '@/components/layout/ThreeColumn.vue' import ThreeColumn from '@/components/layout/ThreeColumn.vue'
import ReactionDisplay from '@/components/reaction/ReactionDisplay.vue' import ReactionDisplay from '@/components/reaction/ReactionDisplay.vue'
import ReactionList from '@/components/reaction/ReactionList.vue' import ReactionList from '@/components/reaction/ReactionList.vue'
@@ -22,14 +23,14 @@ onMounted(() => {
</script> </script>
<template> <template>
<ThreeColumn> <ThreeColumn :show-details="!!id">
<template #list> <template #list>
<ReactionList /> <ReactionList />
</template> </template>
<template #single> <template #single>
<div v-if="!id" class="flex h-full w-full items-center justify-center text-lg text-gray-500"> <ColumnBody v-if="!id" class="items-center justify-center text-lg text-gray-500">
No reaction selected No reaction selected
</div> </ColumnBody>
<ReactionNew v-else-if="id === 'new'" key="new" /> <ReactionNew v-else-if="id === 'new'" key="new" />
<ReactionDisplay v-else :key="id" :id="id" /> <ReactionDisplay v-else :key="id" :id="id" />
</template> </template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import TanView from '@/components/TanView.vue' import TanView from '@/components/TanView.vue'
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ThreeColumn from '@/components/layout/ThreeColumn.vue' import ThreeColumn from '@/components/layout/ThreeColumn.vue'
import TicketDisplay from '@/components/ticket/TicketDisplay.vue' import TicketDisplay from '@/components/ticket/TicketDisplay.vue'
import TicketList from '@/components/ticket/TicketList.vue' import TicketList from '@/components/ticket/TicketList.vue'
@@ -41,20 +42,17 @@ onMounted(() => {
</script> </script>
<template> <template>
<ThreeColumn> <ThreeColumn :show-details="!!id">
<template #list> <template #list>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="selectedType"> <TanView :isError="isError" :isPending="isPending" :error="error">
<TicketList v-if="selectedType" :key="selectedType.id" :selectedType="selectedType" /> <TicketList v-if="selectedType" :key="selectedType.id" :selectedType="selectedType" />
</TanView> </TanView>
</template> </template>
<template #single> <template #single>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="selectedType"> <TanView :isError="isError" :isPending="isPending" :error="error">
<div <ColumnBody v-if="!id" class="items-center justify-center text-lg text-gray-500">
v-if="!id"
class="flex h-full w-full items-center justify-center text-lg text-gray-500"
>
No ticket selected No ticket selected
</div> </ColumnBody>
<TicketDisplay v-else-if="selectedType" :key="id" :selectedType="selectedType" /> <TicketDisplay v-else-if="selectedType" :key="id" :selectedType="selectedType" />
</TanView> </TanView>
</template> </template>