Compare commits

..

20 Commits

Author SHA1 Message Date
Jonas Plum
baba5b7a45 feat: docker entrypoint with environment variables (#1112) 2024-11-06 01:52:48 +01:00
Jonas Plum
d1cf75ab79 refactor: subcommands (#1111) 2024-11-06 01:21:31 +01:00
Jonas Plum
38a89f2c94 fix: docker latest image (#1110) 2024-11-04 23:08:28 +01:00
Jonas Plum
8c36ea5243 feat: scheduler example (#1109) 2024-11-04 23:07:17 +01:00
Jonas Plum
a2bdeecb0d feat: scheduler (#1108) 2024-11-04 22:30:20 +01:00
Jonas Plum
42797509f7 fix: set-app-url (#1107) 2024-11-04 20:50:18 +00:00
Jonas Plum
70ba16a6bd feat: docker healthcheck (#1106) 2024-11-04 20:47:55 +00:00
Jonas Plum
f42de34780 fix: ci docker tags 2024-09-30 03:55:39 +02:00
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
100 changed files with 1354 additions and 1488 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,14 @@ builds:
- linux - linux
- darwin - darwin
dockers:
- ids: [ catalyst ]
dockerfile: docker/Dockerfile
image_templates:
- "ghcr.io/securitybrewery/catalyst:main"
- "{{if not .Prerelease}}ghcr.io/securitybrewery/catalyst:latest{{end}}"
- "ghcr.io/securitybrewery/catalyst:{{.Tag}}"
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

@@ -39,15 +39,38 @@ build-ui:
cd ui && bun install cd ui && bun install
cd ui && bun build-only cd ui && bun build-only
.PHONY: build
build: build-ui
@echo "Building..."
go build -o catalyst .
.PHONY: build-linux
build-linux: build-ui
@echo "Building..."
GOOS=linux GOARCH=amd64 go build -o catalyst .
.PHONY: docker
docker: build-linux
@echo "Building Docker image..."
docker build -f docker/Dockerfile -t catalyst .
.PHONY: dev .PHONY: dev
dev: dev:
@echo "Running..." @echo "Running..."
rm -rf catalyst_data rm -rf catalyst_data
go run . admin create admin@catalyst-soar.com 1234567890 go run . admin create admin@catalyst-soar.com 1234567890
go run . set-feature-flags dev
go run . fake-data go run . fake-data
go run . serve go run . serve --app-url http://localhost:8090 --flags dev
.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 . fake-data --users 100 --tickets 10000
go run . serve --app-url http://localhost:8090 --flags dev
.PHONY: serve-ui
serve-ui: serve-ui:
cd ui && bun dev --port 3000 cd ui && bun dev --port 3000

View File

@@ -23,31 +23,47 @@ func App(dir string, test bool) (*pocketbase.PocketBase, error) {
DefaultDataDir: dir, DefaultDataDir: dir,
}) })
var appURL string
app.RootCmd.PersistentFlags().StringVar(&appURL, "app-url", "", "the app's URL")
var flags []string
app.RootCmd.PersistentFlags().StringSliceVar(&flags, "flags", nil, "feature flags")
_ = app.RootCmd.ParseFlags(os.Args[1:])
app.RootCmd.AddCommand(fakeDataCmd(app))
webhook.BindHooks(app) webhook.BindHooks(app)
reaction.BindHooks(app, test) reaction.BindHooks(app, test)
app.OnBeforeServe().Add(addRoutes())
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error { app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
if err := MigrateDBs(e.App); err != nil {
return err
}
if err := SetFlags(e.App, flags); err != nil {
return err
}
if HasFlag(e.App, "demo") { if HasFlag(e.App, "demo") {
bindDemoHooks(e.App) bindDemoHooks(e.App)
} }
return nil if appURL != "" {
s := e.App.Settings()
s.Meta.AppUrl = appURL
if err := e.App.Dao().SaveSettings(s); err != nil {
return err
}
}
return e.App.RefreshSettings()
}) })
// Register additional commands app.OnBeforeServe().Add(addRoutes())
app.RootCmd.AddCommand(fakeDataCmd(app))
app.RootCmd.AddCommand(setFeatureFlagsCmd(app))
app.RootCmd.AddCommand(setAppURL(app))
if err := app.Bootstrap(); err != nil {
return nil, err
}
if err := MigrateDBs(app); err != nil {
return nil, err
}
return app, nil return app, nil
} }

View File

@@ -12,10 +12,8 @@ func fakeDataCmd(app core.App) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "fake-data", Use: "fake-data",
Run: func(_ *cobra.Command, _ []string) { RunE: func(_ *cobra.Command, _ []string) error {
if err := fakedata.Generate(app, userCount, ticketCount); err != nil { return fakedata.Generate(app, userCount, ticketCount)
app.Logger().Error(err.Error())
}
}, },
} }

View File

@@ -6,7 +6,6 @@ import (
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/spf13/cobra"
"github.com/SecurityBrewery/catalyst/migrations" "github.com/SecurityBrewery/catalyst/migrations"
) )
@@ -85,40 +84,3 @@ func SetFlags(app core.App, args []string) error {
return nil return nil
} }
func setFeatureFlagsCmd(app core.App) *cobra.Command {
return &cobra.Command{
Use: "set-feature-flags",
Run: func(_ *cobra.Command, args []string) {
if err := SetFlags(app, args); err != nil {
app.Logger().Error(err.Error())
}
},
}
}
func setAppURL(app core.App) *cobra.Command {
return &cobra.Command{
Use: "set-app-url",
Run: func(_ *cobra.Command, args []string) {
if len(args) != 1 {
app.Logger().Error("missing app url")
return
}
settings, err := app.Settings().Clone()
if err != nil {
app.Logger().Error(err.Error())
return
}
settings.Meta.AppUrl = args[0]
if err := app.Dao().SaveSettings(settings); err != nil {
app.Logger().Error(err.Error())
}
},
}
}

View File

@@ -18,6 +18,13 @@ func addRoutes() func(*core.ServeEvent) error {
return c.Redirect(http.StatusFound, "/ui/") return c.Redirect(http.StatusFound, "/ui/")
}) })
e.Router.GET("/ui/*", staticFiles()) e.Router.GET("/ui/*", staticFiles())
e.Router.GET("/health", func(c echo.Context) error {
if _, err := Flags(e.App); err != nil {
return err
}
return c.String(http.StatusOK, "OK")
})
e.Router.GET("/api/config", func(c echo.Context) error { e.Router.GET("/api/config", func(c echo.Context) error {
flags, err := Flags(e.App) flags, err := Flags(e.App)

16
docker/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl python3 python3-pip python3-venv
COPY catalyst /usr/local/bin/catalyst
EXPOSE 8080
VOLUME /usr/local/bin/catalyst_data
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
COPY docker/entrypoint.sh /entrypoint.sh
CMD ["/entrypoint.sh"]

15
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# Set the flags
FLAGS=""
if [ -n "$CATALYST_FLAGS" ]; then
FLAGS="$CATALYST_FLAGS"
fi
# Set the app url
APP_URL=""
if [ -n "$CATALYST_APP_URL" ]; then
APP_URL="$CATALYST_APP_URL"
fi
/usr/local/bin/catalyst serve --http 0.0.0.0:8080 --flags "$FLAGS" --app-url "$APP_URL"

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)
@@ -248,6 +248,28 @@ func linkRecords(dao *daos.Dao, created time.Time, record *models.Record) []*mod
return records return records
} }
const createTicketPy = `import sys
import json
import random
import os
from pocketbase import PocketBase
# Connect to the PocketBase server
client = PocketBase(os.environ["CATALYST_APP_URL"])
client.auth_store.save(token=os.environ["CATALYST_TOKEN"])
newtickets = client.collection("tickets").get_list(1, 200, {"filter": 'name = "New Ticket"'})
for ticket in newtickets.items:
client.collection("tickets").delete(ticket.id)
# Create a new ticket
client.collection("tickets").create({
"name": "New Ticket",
"type": "alert",
"open": True,
})`
const alertIngestPy = `import sys const alertIngestPy = `import sys
import json import json
import random import random
@@ -294,6 +316,7 @@ client.collection("tickets").update(ticket["record"]["id"], {
})` })`
const ( const (
triggerSchedule = `{"expression":"12 * * * *"}`
triggerWebhook = `{"token":"1234567890","path":"webhook"}` triggerWebhook = `{"token":"1234567890","path":"webhook"}`
triggerHook = `{"collections":["tickets"],"events":["create"]}` triggerHook = `{"collections":["tickets"],"events":["create"]}`
) )
@@ -306,6 +329,24 @@ func reactionRecords(dao *daos.Dao) []*models.Record {
panic(err) panic(err)
} }
createTicketActionData, err := json.Marshal(map[string]interface{}{
"requirements": "pocketbase",
"script": createTicketPy,
})
if err != nil {
panic(err)
}
record := models.NewRecord(collection)
record.SetId("w_" + security.PseudorandomString(10))
record.Set("name", "Create New Ticket")
record.Set("trigger", "schedule")
record.Set("triggerdata", triggerSchedule)
record.Set("action", "python")
record.Set("actiondata", string(createTicketActionData))
records = append(records, record)
alertIngestActionData, err := json.Marshal(map[string]interface{}{ alertIngestActionData, err := json.Marshal(map[string]interface{}{
"requirements": "pocketbase", "requirements": "pocketbase",
"script": alertIngestPy, "script": alertIngestPy,
@@ -314,9 +355,9 @@ func reactionRecords(dao *daos.Dao) []*models.Record {
panic(err) panic(err)
} }
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 +375,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

@@ -0,0 +1,28 @@
package migrations
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/schema"
)
func reactionsUpdateUp(db dbx.Builder) error {
dao := daos.New(db)
triggers := []string{"webhook", "hook", "schedule"}
col, err := dao.FindCollectionByNameOrId(ReactionCollectionName)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", ReactionCollectionName, err)
}
field := col.Schema.GetFieldByName("trigger")
field.Options = &schema.SelectOptions{MaxSelect: 1, Values: triggers}
col.Schema.AddField(field)
return dao.SaveCollection(col)
}

View File

@@ -11,4 +11,7 @@ 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")
migrations.Register(reactionsUpdateUp, nil, "1700000009_reactions_update.go")
} }

View File

@@ -0,0 +1,101 @@
package schedule
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/cron"
"go.uber.org/multierr"
"github.com/SecurityBrewery/catalyst/migrations"
"github.com/SecurityBrewery/catalyst/reaction/action"
)
type Schedule struct {
Expression string `json:"expression"`
}
func Start(pb *pocketbase.PocketBase) {
scheduler := cron.New()
if err := scheduler.Add("reactions", "* * * * *", func() {
ctx := context.Background()
moment := cron.NewMoment(time.Now())
if err := runSchedule(ctx, pb.App, moment); err != nil {
slog.ErrorContext(ctx, fmt.Sprintf("failed to run hook reaction: %v", err))
}
}); err != nil {
slog.Error(fmt.Sprintf("failed to add cron job: %v", err))
}
scheduler.Start()
}
func runSchedule(ctx context.Context, app core.App, moment *cron.Moment) error {
var errs error
records, err := findByScheduleTrigger(app.Dao(), moment)
if err != nil {
errs = multierr.Append(errs, fmt.Errorf("failed to find schedule reaction: %w", err))
}
if len(records) == 0 {
return nil
}
for _, hook := range records {
_, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), "{}")
if err != nil {
errs = multierr.Append(errs, fmt.Errorf("failed to run hook reaction: %w", err))
}
}
return errs
}
func findByScheduleTrigger(dao *daos.Dao, moment *cron.Moment) ([]*models.Record, error) {
records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "schedule"})
if err != nil {
return nil, fmt.Errorf("failed to find schedule reaction: %w", err)
}
if len(records) == 0 {
return nil, nil
}
var errs error
var matchedRecords []*models.Record
for _, record := range records {
var schedule Schedule
if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &schedule); err != nil {
errs = multierr.Append(errs, err)
continue
}
s, err := cron.NewSchedule(schedule.Expression)
if err != nil {
errs = multierr.Append(errs, err)
continue
}
if s.IsDue(moment) {
matchedRecords = append(matchedRecords, record)
}
}
return matchedRecords, errs
}

View File

@@ -3,11 +3,13 @@ package reaction
import ( import (
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/SecurityBrewery/catalyst/reaction/schedule"
"github.com/SecurityBrewery/catalyst/reaction/trigger/hook" "github.com/SecurityBrewery/catalyst/reaction/trigger/hook"
"github.com/SecurityBrewery/catalyst/reaction/trigger/webhook" "github.com/SecurityBrewery/catalyst/reaction/trigger/webhook"
) )
func BindHooks(pb *pocketbase.PocketBase, test bool) { func BindHooks(pb *pocketbase.PocketBase, test bool) {
schedule.Start(pb)
hook.BindHooks(pb, test) hook.BindHooks(pb, test)
webhook.BindHooks(pb) webhook.BindHooks(pb)
} }

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
} }
var errs error
for _, hook := range hooks {
_, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), string(payload)) _, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), string(payload))
if err != nil { if err != nil {
return fmt.Errorf("failed to run hook reaction: %w", err) 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
} }

View File

@@ -26,6 +26,10 @@ func App(t *testing.T) (*pocketbase.PocketBase, *Counter, func()) {
t.Fatal(err) t.Fatal(err)
} }
if err := baseApp.Bootstrap(); err != nil {
t.Fatal(fmt.Errorf("failed to bootstrap: %w", err))
}
baseApp.Settings().Logs.MaxDays = 0 baseApp.Settings().Logs.MaxDays = 0
defaultTestData(t, baseApp) defaultTestData(t, baseApp)

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,6 +33,7 @@ const {
<template> <template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Card> <Card>
<TanView :isError="isError" :isPending="isPending" :error="error">
<div v-if="tasks && tasks.length === 0" class="p-2 text-center text-sm text-gray-500"> <div v-if="tasks && tasks.length === 0" class="p-2 text-center text-sm text-gray-500">
No open tasks No open tasks
</div> </div>
@@ -42,7 +44,12 @@ const {
name: 'tickets', name: 'tickets',
params: { type: task.expand.ticket.type, id: task.expand.ticket.id } params: { type: task.expand.ticket.type, id: task.expand.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 {{ task.expand.ticket.name }} Go to {{ task.expand.ticket.name }}
@@ -50,6 +57,7 @@ const {
</span> </span>
</RouterLink> </RouterLink>
</PanelListElement> </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,24 +8,28 @@ 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="
cn(
'flex min-w-48 shrink-0 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
catalystStore.sidebarCollapsed && 'min-w-[50px]'
)
"
>
<div class="flex h-[57px] items-center border-b bg-background"> <div class="flex h-[57px] items-center border-b bg-background">
<img <CatalystLogo
src="@/assets/flask.svg" class="size-8"
alt="Catalyst" :class="{
class="h-8 w-8 dark:hidden" 'flex-1': catalystStore.sidebarCollapsed,
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !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> <h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
</div> </div>
@@ -59,13 +64,22 @@ const catalystStore = useCatalystStore()
<Separator /> <Separator />
<UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" /> <UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator /> <Separator />
<div :class="cn('flex h-14 items-center px-3', !catalystStore.sidebarCollapsed && 'px-2')">
<Button <Button
variant="ghost" variant="ghost"
@click="catalystStore.toggleSidebar()" @click="catalystStore.toggleSidebar()"
size="sm" size="default"
class="m-2 justify-start px-3.5" :class="
cn(
'p-0',
catalystStore.sidebarCollapsed && 'w-9',
!catalystStore.sidebarCollapsed && 'w-full justify-start px-3'
)
"
> >
<Menu class="size-4" /> <Menu class="size-4" />
<span v-if="!catalystStore.sidebarCollapsed" class="ml-2">Toggle Sidebar</span> <span v-if="!catalystStore.sidebarCollapsed" class="ml-2">Toggle Sidebar</span>
</Button> </Button>
</div>
</div>
</template> </template>

View File

@@ -3,30 +3,38 @@ 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">
</div>
<div class="w-72 flex-initial border-r">
<slot name="list" /> <slot name="list" />
</div> </div>
<div class="flex-1"> </div>
<div
:class="
cn('flex-1 overflow-hidden', !showDetails && 'hidden sm:flex', showDetails && 'flex')
"
>
<div class="flex h-full w-full flex-1 flex-col">
<slot name="single" /> <slot name="single" />
</div> </div>
</div> </div>
</div>
</TooltipProvider> </TooltipProvider>
</template> </template>

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
:class="
cn(
'flex min-w-48 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
catalystStore.sidebarCollapsed && 'min-w-[50px]'
)
"
>
<SideBar /> <SideBar />
</div> <div class="flex h-full w-full flex-col">
<slot /> <slot />
</div> </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,9 +76,12 @@ 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">
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
<div class="ml-auto"> <div class="ml-auto">
<DeleteDialog <DeleteDialog
v-if="reaction" v-if="reaction"
@@ -86,14 +93,12 @@ onUnmounted(() => {
:queryKey="['reactions']" :queryKey="['reactions']"
/> />
</div> </div>
</div> </ColumnHeader>
<Separator />
<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

@@ -2,6 +2,7 @@
import ActionPythonFormFields from '@/components/reaction/ActionPythonFormFields.vue' import ActionPythonFormFields from '@/components/reaction/ActionPythonFormFields.vue'
import ActionWebhookFormFields from '@/components/reaction/ActionWebhookFormFields.vue' import ActionWebhookFormFields from '@/components/reaction/ActionWebhookFormFields.vue'
import TriggerHookFormFields from '@/components/reaction/TriggerHookFormFields.vue' import TriggerHookFormFields from '@/components/reaction/TriggerHookFormFields.vue'
import TriggerScheduleFormFields from '@/components/reaction/TriggerScheduleFormFields.vue'
import TriggerWebhookFormFields from '@/components/reaction/TriggerWebhookFormFields.vue' import TriggerWebhookFormFields from '@/components/reaction/TriggerWebhookFormFields.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 } from '@/components/ui/button'
@@ -67,6 +68,25 @@ defineRule('required', (value: string) => {
return true return true
}) })
defineRule('triggerdata.expression', (value: string) => {
if (values.trigger !== 'schedule') {
return true
}
if (!value) {
return 'This field is required'
}
const macros = ['@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly']
if (macros.includes(value)) {
return true
}
const expression =
/^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/
if (value.match(expression)) {
return true
}
return 'Invalid cron expression'
})
defineRule('triggerdata.token', (value: string) => { defineRule('triggerdata.token', (value: string) => {
return true return true
}) })
@@ -160,6 +180,7 @@ const { handleSubmit, validate, values } = useForm({
validationSchema: { validationSchema: {
name: 'required', name: 'required',
trigger: 'required', trigger: 'required',
'triggerdata.expression': 'triggerdata.expression',
'triggerdata.token': 'triggerdata.token', 'triggerdata.token': 'triggerdata.token',
'triggerdata.path': 'triggerdata.path', 'triggerdata.path': 'triggerdata.path',
'triggerdata.collections': 'triggerdata.collections', 'triggerdata.collections': 'triggerdata.collections',
@@ -227,7 +248,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 +260,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>
@@ -263,6 +284,7 @@ const curlExample = computed(() => {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem value="schedule">Schedule</SelectItem>
<SelectItem value="webhook">HTTP / Webhook</SelectItem> <SelectItem value="webhook">HTTP / Webhook</SelectItem>
<SelectItem value="hook">Collection Hook</SelectItem> <SelectItem value="hook">Collection Hook</SelectItem>
</SelectGroup> </SelectGroup>
@@ -277,7 +299,8 @@ const curlExample = computed(() => {
</FormItem> </FormItem>
</FormField> </FormField>
<TriggerWebhookFormFields v-if="values.trigger === 'webhook'" /> <TriggerScheduleFormFields v-if="values.trigger === 'schedule'" />
<TriggerWebhookFormFields v-else-if="values.trigger === 'webhook'" />
<TriggerHookFormFields v-else-if="values.trigger === 'hook'" /> <TriggerHookFormFields v-else-if="values.trigger === 'hook'" />
<div v-if="values.trigger === 'webhook'"> <div v-if="values.trigger === 'webhook'">

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'
@@ -32,7 +32,9 @@ const subtitle = (reaction: Reaction) =>
triggerNiceName(reaction) + ' to ' + reactionNiceName(reaction) triggerNiceName(reaction) + ' to ' + reactionNiceName(reaction)
const triggerNiceName = (reaction: Reaction) => { const triggerNiceName = (reaction: Reaction) => {
if (reaction.trigger === 'hook') { if (reaction.trigger === 'schedule') {
return 'Schedule'
} else if (reaction.trigger === 'hook') {
return 'Collection Hook' return 'Collection Hook'
} else if (reaction.trigger === 'webhook') { } else if (reaction.trigger === 'webhook') {
return 'HTTP / Webhook' return 'HTTP / Webhook'
@@ -63,16 +65,13 @@ 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">
<h1 class="text-xl font-bold">Reactions</h1>
<div class="ml-auto"> <div class="ml-auto">
<Button variant="ghost" @click="openNew"> New Reaction</Button> <Button variant="ghost" @click="openNew">New Reaction</Button>
</div> </div>
</div> </ColumnHeader>
<Separator /> <div class="mt-2 flex flex-1 flex-col gap-2 p-2 pt-0">
<div class="mt-2 flex flex-1 flex-col gap-2 p-4 pt-0">
<TransitionGroup name="list" appear> <TransitionGroup name="list" appear>
<ResourceListElement <ResourceListElement
v-for="reaction in reactions" v-for="reaction in reactions"
@@ -89,6 +88,5 @@ onMounted(() => {
</ResourceListElement> </ResourceListElement>
</TransitionGroup> </TransitionGroup>
</div> </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

@@ -0,0 +1,42 @@
<script setup lang="ts">
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
</script>
<template>
<FormField name="triggerdata.expression" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="expression" class="text-right"> Cron Expression </FormLabel>
<FormControl>
<Input
id="expression"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a cron expression"
/>
</FormControl>
<FormDescription>
<p class="text-sm text-gray-500">
A cron expression or macro to schedule the trigger. Example: <code>0 * * * *</code> (every
hour) or <code>@daily</code> (every day).
<a
href="https://en.wikipedia.org/wiki/Cron#CRON_expression"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 underline"
>
Learn more
</a>
</p>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -15,15 +15,11 @@ import { Input } from '@/components/ui/input'
<FormItem> <FormItem>
<FormLabel for="token" class="text-right">Token</FormLabel> <FormLabel for="token" class="text-right">Token</FormLabel>
<FormControl> <FormControl>
<Input <Input id="token" class="col-span-3" v-bind="componentField" placeholder="Enter a token" />
id="token"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a token (e.g. 'xyz...')"
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Optional. Include an authorization token in the request headers. Optional. Include an authorization token in the request headers. Example:
<code>Bearer YOUR_TOKEN</code>
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -33,14 +29,11 @@ import { Input } from '@/components/ui/input'
<FormItem> <FormItem>
<FormLabel for="path" class="text-right">Path</FormLabel> <FormLabel for="path" class="text-right">Path</FormLabel>
<FormControl> <FormControl>
<Input <Input id="path" class="col-span-3" v-bind="componentField" placeholder="Enter a path" />
id="path"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a path (e.g. 'action1')"
/>
</FormControl> </FormControl>
<FormDescription> Specify the path to trigger the reaction. </FormDescription> <FormDescription>
Specify the path to trigger the reaction. Example: <code>action1</code>
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>

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,8 +75,15 @@ 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
@click="router.push({ name: 'tickets', params: { type: ticket.type } })"
variant="outline"
class="sm:hidden"
>
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<div> <div>
@@ -141,7 +149,7 @@ const closeTicketDialogOpen = ref(false)
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Change User</TooltipContent> <TooltipContent>Change User</TooltipContent>
</Tooltip> </Tooltip>
</div> <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,15 +101,20 @@ 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
v-if="!editMode"
variant="outline"
class="float-right h-8 gap-2"
@click="edit"
>
<Edit class="h-3.5 w-3.5" /> <Edit class="h-3.5 w-3.5" />
<span>Edit</span> <span>Edit</span>
</Button> </Button>
@@ -123,7 +129,7 @@ const updateDescription = (value: string) => (message.value = value)
/> />
</Card> </Card>
<Separator /> <Separator />
<Tabs default-value="timeline" class="flex flex-1 flex-col overflow-hidden"> <Tabs default-value="timeline" class="flex flex-1 flex-col">
<TabsList> <TabsList>
<TabsTrigger value="timeline"> <TabsTrigger value="timeline">
Timeline Timeline
@@ -133,19 +139,23 @@ const updateDescription = (value: string) => (message.value = value)
ticket.expand.timeline_via_ticket.length > 0 ticket.expand.timeline_via_ticket.length > 0
" "
variant="outline" variant="outline"
class="ml-2" class="ml-2 hidden sm:inline-flex"
> >
{{ {{
ticket.expand.timeline_via_ticket ? ticket.expand.timeline_via_ticket.length : 0 ticket.expand.timeline_via_ticket
? ticket.expand.timeline_via_ticket.length
: 0
}} }}
</Badge> </Badge>
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="tasks"> <TabsTrigger value="tasks">
Tasks Tasks
<Badge <Badge
v-if="ticket.expand.tasks_via_ticket && ticket.expand.tasks_via_ticket.length > 0" v-if="
ticket.expand.tasks_via_ticket && ticket.expand.tasks_via_ticket.length > 0
"
variant="outline" variant="outline"
class="ml-2" class="ml-2 hidden sm:inline-flex"
> >
{{ ticket.expand.tasks_via_ticket ? ticket.expand.tasks_via_ticket.length : 0 }} {{ ticket.expand.tasks_via_ticket ? ticket.expand.tasks_via_ticket.length : 0 }}
<StatusIcon :status="taskStatus" class="size-6" /> <StatusIcon :status="taskStatus" class="size-6" />
@@ -159,10 +169,12 @@ const updateDescription = (value: string) => (message.value = value)
ticket.expand.comments_via_ticket.length > 0 ticket.expand.comments_via_ticket.length > 0
" "
variant="outline" variant="outline"
class="ml-2" class="ml-2 hidden sm:inline-flex"
> >
{{ {{
ticket.expand.comments_via_ticket ? ticket.expand.comments_via_ticket.length : 0 ticket.expand.comments_via_ticket
? ticket.expand.comments_via_ticket.length
: 0
}} }}
</Badge> </Badge>
</TabsTrigger> </TabsTrigger>
@@ -179,8 +191,7 @@ const updateDescription = (value: string) => (message.value = value)
</Tabs> </Tabs>
<Separator class="xl:hidden" /> <Separator class="xl:hidden" />
</div> </div>
<ScrollArea> <div class="flex flex-col gap-4 xl:w-96 xl:flex-initial">
<div class="flex flex-initial flex-col gap-4 p-4 xl:w-96">
<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,52 +121,30 @@ 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">
<h1 class="text-xl font-bold">
{{ selectedType?.plural }}
</h1>
<div class="ml-auto"> <div class="ml-auto">
<TicketNewDialog :selectedType="selectedType" /> <TicketNewDialog :selectedType="selectedType" />
</div> </div>
</div> </ColumnHeader>
<Separator />
<Tabs v-model="tab" class="flex flex-1 flex-col overflow-hidden"> <Tabs v-model="tab" class="flex flex-1 flex-col overflow-hidden">
<div class="flex items-center justify-between px-4 pt-2"> <div class="flex items-center justify-between px-2 pt-2">
<TabsList> <TabsList>
<TabsTrigger value="all"> All</TabsTrigger> <TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="open"> Open</TabsTrigger> <TabsTrigger value="open">Open</TabsTrigger>
<TabsTrigger value="closed"> Closed</TabsTrigger> <TabsTrigger value="closed">Closed</TabsTrigger>
</TabsList> </TabsList>
<!-- Button variant="outline" size="sm" class="h-7 gap-1 rounded-md px-3"> <!-- Button variant="outline" size="sm" class="h-7 gap-1 rounded-md px-3">
<ListFilter class="h-3.5 w-3.5" /> <ListFilter class="h-3.5 w-3.5" />
<span class="sr-only sm:not-sr-only">Filter</span> <span class="sr-only sm:not-sr-only">Filter</span>
</Button--> </Button-->
</div> </div>
<div class="px-4 py-2"> <div class="p-2">
<form> <form>
<div class="relative flex flex-row items-center"> <div class="relative flex flex-row items-center">
<Input v-model="searchValue" placeholder="Search" @keydown.enter.prevent class="pl-8" /> <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"> <span class="absolute inset-y-0 start-0 flex items-center justify-center px-2">
<Search class="size-4 text-muted-foreground" /> <Search class="size-4 text-muted-foreground" />
</span> </span>
<div>
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger as-child>
<Info class="ml-2 size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p class="w-64">
Search name, description, or owner. Links, tasks, comments, files, and
timeline messages are also searched, but cause unreliable results if there are
more than 1000 records.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div> </div>
</form> </form>
</div> </div>
@@ -179,13 +152,13 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
<div v-if="isPending" class="flex h-full w-full items-center justify-center"> <div v-if="isPending" class="flex h-full w-full items-center justify-center">
<LoaderCircle class="h-16 w-16 animate-spin text-primary" /> <LoaderCircle class="h-16 w-16 animate-spin text-primary" />
</div> </div>
<Alert v-else-if="isError" variant="destructive" class="mb-4 h-screen w-screen"> <Alert v-else-if="isError" variant="destructive" class="mb-2 h-screen w-screen">
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription> <AlertDescription>{{ error }}</AlertDescription>
</Alert> </Alert>
<ScrollArea v-else-if="ticketItems" class="flex-1"> <div v-else-if="ticketItems" class="flex-1 overflow-y-auto overflow-x-hidden">
<TicketListList :tickets="ticketItems.items" /> <TicketListList :tickets="ticketItems.items" />
</ScrollArea> </div>
<Separator /> <Separator />
<div class="my-2 flex items-center justify-center"> <div class="my-2 flex items-center justify-center">
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
@@ -193,7 +166,7 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
{{ ticketItems ? ticketItems.totalItems : '?' }} tickets {{ ticketItems ? ticketItems.totalItems : '?' }} tickets
</span> </span>
</div> </div>
<div class="mb-4 flex items-center justify-center"> <div class="mb-2 flex items-center justify-center">
<Pagination <Pagination
v-slot="{ page }" v-slot="{ page }"
:total="ticketItems ? ticketItems.totalItems : 0" :total="ticketItems ? ticketItems.totalItems : 0"
@@ -213,21 +186,16 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
:value="item.value" :value="item.value"
as-child as-child
> >
<Button <Button class="h-10 w-10 p-0" :variant="item.value === page ? 'default' : 'outline'">
class="h-10 w-10 p-0"
:variant="item.value === page ? 'default' : 'outline'"
>
{{ item.value }} {{ item.value }}
</Button> </Button>
</PaginationListItem> </PaginationListItem>
<PaginationEllipsis v-else :key="item.type" :index="index" /> <PaginationEllipsis v-else :key="item.type" :index="index" />
</template> </template>
<PaginationNext /> <PaginationNext />
<PaginationLast /> <PaginationLast />
</PaginationList> </PaginationList>
</Pagination> </Pagination>
</div> </div>
</Tabs> </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">
<div class="flex flex-row items-center">
<Checkbox :checked="!task.open" class="mr-2" @click="check(task)" /> <Checkbox :checked="!task.open" class="mr-2" @click="check(task)" />
<DynamicInput <DynamicInput
:modelValue="task.name" :modelValue="task.name"
@update:modelValue="updateTaskName(task.id, $event)" @update:modelValue="updateTaskName(task.id, $event)"
class="mr-2 flex-1" 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,14 +47,10 @@ 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>
<div
class="m-auto grid max-w-7xl grid-cols-1 grid-rows-[100px_100px_100px_100px] gap-4 p-4 md:grid-cols-2 md:grid-rows-[100px_100px] xl:grid-cols-4 xl:grid-rows-[100px]"
> >
<Card> <Card>
<CardHeader> <CardHeader>
@@ -75,8 +72,8 @@ onMounted(() => {
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle>{{ count('reactions') }}</CardTitle>
<CardDescription></CardDescription> <CardDescription>Reactions</CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
<Card> <Card>
@@ -85,7 +82,7 @@ onMounted(() => {
</CardHeader> </CardHeader>
<CardContent class="flex flex-1 flex-col gap-1"> <CardContent class="flex flex-1 flex-col gap-1">
<a <a
href="https://catalyst-soar.com/docs/category/catalyst-handbook" href="https://catalyst.security-brewery.com/docs/category/catalyst-handbook"
target="_blank" target="_blank"
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent" class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
> >
@@ -134,8 +131,7 @@ onMounted(() => {
<OpenTasks /> <OpenTasks />
</CardContent> </CardContent>
</Card> </Card>
</div> </ColumnBodyContainer>
</ScrollArea> </ColumnBody>
</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>