feat: WebSocket based implementation of server sent events for cache busting (#527)

* rough implementation of WS based event system for server side notifications of mutation

* fix test construction

* fix deadlock on event bus

* disable linter error

* add item mutation events

* remove old event bus code

* refactor event system to use composables

* refresh items table when new item is added

* fix create form errors

* cleanup unnecessary calls

* fix importer erorrs + limit fn calls on import
This commit is contained in:
Hayden 2023-08-02 13:00:57 -08:00 committed by GitHub
parent cceec06148
commit 2cbcc8bb1d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 458 additions and 208 deletions

View file

@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/hay-kot/homebox/backend/internal/core/services" "github.com/hay-kot/homebox/backend/internal/core/services"
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/hay-kot/homebox/backend/internal/data/ent" "github.com/hay-kot/homebox/backend/internal/data/ent"
"github.com/hay-kot/homebox/backend/internal/data/repo" "github.com/hay-kot/homebox/backend/internal/data/repo"
"github.com/hay-kot/homebox/backend/internal/sys/config" "github.com/hay-kot/homebox/backend/internal/sys/config"
@ -18,6 +19,7 @@ type app struct {
server *server.Server server *server.Server
repos *repo.AllRepos repos *repo.AllRepos
services *services.AllServices services *services.AllServices
bus *eventbus.EventBus
} }
func new(conf *config.Config) *app { func new(conf *config.Config) *app {

View file

@ -1,12 +1,18 @@
package v1 package v1
import ( import (
"fmt"
"net/http" "net/http"
"github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/core/services" "github.com/hay-kot/homebox/backend/internal/core/services"
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/hay-kot/homebox/backend/internal/data/repo" "github.com/hay-kot/homebox/backend/internal/data/repo"
"github.com/hay-kot/httpkit/errchain" "github.com/hay-kot/httpkit/errchain"
"github.com/hay-kot/httpkit/server" "github.com/hay-kot/httpkit/server"
"github.com/rs/zerolog/log"
"github.com/olahol/melody"
) )
type Results[T any] struct { type Results[T any] struct {
@ -49,6 +55,7 @@ type V1Controller struct {
maxUploadSize int64 maxUploadSize int64
isDemo bool isDemo bool
allowRegistration bool allowRegistration bool
bus *eventbus.EventBus
} }
type ( type (
@ -77,11 +84,12 @@ func BaseUrlFunc(prefix string) func(s string) string {
} }
} }
func NewControllerV1(svc *services.AllServices, repos *repo.AllRepos, options ...func(*V1Controller)) *V1Controller { func NewControllerV1(svc *services.AllServices, repos *repo.AllRepos, bus *eventbus.EventBus, options ...func(*V1Controller)) *V1Controller {
ctrl := &V1Controller{ ctrl := &V1Controller{
repo: repos, repo: repos,
svc: svc, svc: svc,
allowRegistration: true, allowRegistration: true,
bus: bus,
} }
for _, opt := range options { for _, opt := range options {
@ -110,3 +118,42 @@ func (ctrl *V1Controller) HandleBase(ready ReadyFunc, build Build) errchain.Hand
}) })
} }
} }
func (ctrl *V1Controller) HandleCacheWS() errchain.HandlerFunc {
m := melody.New()
m.HandleConnect(func(s *melody.Session) {
auth := services.NewContext(s.Request.Context())
s.Set("gid", auth.GID)
})
factory := func(e string) func(data any) {
return func(data any) {
eventData, ok := data.(eventbus.GroupMutationEvent)
if !ok {
log.Log().Msgf("invalid event data: %v", data)
return
}
jsonStr := fmt.Sprintf(`{"event": "%s"}`, e)
_ = m.BroadcastFilter([]byte(jsonStr), func(s *melody.Session) bool {
groupIDStr, ok := s.Get("gid")
if !ok {
return false
}
GID := groupIDStr.(uuid.UUID)
return GID == eventData.GID
})
}
}
ctrl.bus.Subscribe(eventbus.EventLabelMutation, factory("label.mutation"))
ctrl.bus.Subscribe(eventbus.EventLocationMutation, factory("location.mutation"))
ctrl.bus.Subscribe(eventbus.EventItemMutation, factory("item.mutation"))
return func(w http.ResponseWriter, r *http.Request) error {
return m.HandleRequest(w, r)
}
}

View file

@ -14,6 +14,7 @@ import (
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/hay-kot/homebox/backend/internal/core/services" "github.com/hay-kot/homebox/backend/internal/core/services"
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/hay-kot/homebox/backend/internal/data/ent" "github.com/hay-kot/homebox/backend/internal/data/ent"
"github.com/hay-kot/homebox/backend/internal/data/migrations" "github.com/hay-kot/homebox/backend/internal/data/migrations"
"github.com/hay-kot/homebox/backend/internal/data/repo" "github.com/hay-kot/homebox/backend/internal/data/repo"
@ -116,8 +117,9 @@ func run(cfg *config.Config) error {
return err return err
} }
app.bus = eventbus.New()
app.db = c app.db = c
app.repos = repo.New(c, cfg.Storage.Data) app.repos = repo.New(c, app.bus, cfg.Storage.Data)
app.services = services.New( app.services = services.New(
app.repos, app.repos,
services.WithAutoIncrementAssetID(cfg.Options.AutoIncrementAssetID), services.WithAutoIncrementAssetID(cfg.Options.AutoIncrementAssetID),
@ -150,6 +152,8 @@ func run(cfg *config.Config) error {
// ========================================================================= // =========================================================================
// Start Reoccurring Tasks // Start Reoccurring Tasks
go app.bus.Run()
go app.startBgTask(time.Duration(24)*time.Hour, func() { go app.startBgTask(time.Duration(24)*time.Hour, func() {
_, err := app.repos.AuthTokens.PurgeExpiredTokens(context.Background()) _, err := app.repos.AuthTokens.PurgeExpiredTokens(context.Background())
if err != nil { if err != nil {

View file

@ -51,6 +51,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
v1Ctrl := v1.NewControllerV1( v1Ctrl := v1.NewControllerV1(
a.services, a.services,
a.repos, a.repos,
a.bus,
v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize), v1.WithMaxUploadSize(a.conf.Web.MaxUploadSize),
v1.WithRegistration(a.conf.Options.AllowRegistration), v1.WithRegistration(a.conf.Options.AllowRegistration),
v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode v1.WithDemoStatus(a.conf.Demo), // Disable Password Change in Demo Mode
@ -70,6 +71,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
a.mwRoles(RoleModeOr, authroles.RoleUser.String()), a.mwRoles(RoleModeOr, authroles.RoleUser.String()),
} }
r.Get(v1Base("/ws/events"), chain.ToHandlerFunc(v1Ctrl.HandleCacheWS(), userMW...))
r.Get(v1Base("/users/self"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelf(), userMW...)) r.Get(v1Base("/users/self"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelf(), userMW...))
r.Put(v1Base("/users/self"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelfUpdate(), userMW...)) r.Put(v1Base("/users/self"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelfUpdate(), userMW...))
r.Delete(v1Base("/users/self"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelfDelete(), userMW...)) r.Delete(v1Base("/users/self"), chain.ToHandlerFunc(v1Ctrl.HandleUserSelfDelete(), userMW...))
@ -153,7 +155,6 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Get(v1Base("/reporting/bill-of-materials"), chain.ToHandlerFunc(v1Ctrl.HandleBillOfMaterialsExport(), userMW...)) r.Get(v1Base("/reporting/bill-of-materials"), chain.ToHandlerFunc(v1Ctrl.HandleBillOfMaterialsExport(), userMW...))
r.NotFound(chain.ToHandlerFunc(notFoundHandler())) r.NotFound(chain.ToHandlerFunc(notFoundHandler()))
} }
func registerMimes() { func registerMimes() {

View file

@ -14,6 +14,7 @@ require (
github.com/gorilla/schema v1.2.0 github.com/gorilla/schema v1.2.0
github.com/hay-kot/httpkit v0.0.3 github.com/hay-kot/httpkit v0.0.3
github.com/mattn/go-sqlite3 v1.14.17 github.com/mattn/go-sqlite3 v1.14.17
github.com/olahol/melody v1.1.4
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.29.1 github.com/rs/zerolog v1.29.1
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
@ -43,6 +44,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-cmp v0.5.9 // indirect github.com/google/go-cmp v0.5.9 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/hashicorp/hcl/v2 v2.17.0 // indirect github.com/hashicorp/hcl/v2 v2.17.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect

View file

@ -410,6 +410,8 @@ github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/consul/api v1.15.3/go.mod h1:/g/qgcoBcEXALCNZgRRisyTW0nY86++L0KbeAMXYCeY= github.com/hashicorp/consul/api v1.15.3/go.mod h1:/g/qgcoBcEXALCNZgRRisyTW0nY86++L0KbeAMXYCeY=
@ -511,8 +513,6 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
@ -538,8 +538,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRW
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olahol/melody v1.1.4 h1:RQHfKZkQmDxI0+SLZRNBCn4LiXdqxLKRGSkT8Dyoe/E=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/olahol/melody v1.1.4/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
@ -611,10 +611,7 @@ github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasO
github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU=
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.14.0/go.mod h1:WT//axPky3FdvXHzGw33dNdXXXfFQqmEalje+egj8As= github.com/spf13/viper v1.14.0/go.mod h1:WT//axPky3FdvXHzGw33dNdXXXfFQqmEalje+egj8As=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View file

@ -6,6 +6,7 @@ import (
"os" "os"
"testing" "testing"
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/hay-kot/homebox/backend/internal/data/ent" "github.com/hay-kot/homebox/backend/internal/data/ent"
"github.com/hay-kot/homebox/backend/internal/data/repo" "github.com/hay-kot/homebox/backend/internal/data/repo"
"github.com/hay-kot/homebox/backend/pkgs/faker" "github.com/hay-kot/homebox/backend/pkgs/faker"
@ -14,6 +15,7 @@ import (
var ( var (
fk = faker.NewFaker() fk = faker.NewFaker()
tbus = eventbus.New()
tCtx = Context{} tCtx = Context{}
tClient *ent.Client tClient *ent.Client
@ -58,7 +60,7 @@ func TestMain(m *testing.M) {
} }
tClient = client tClient = client
tRepos = repo.New(tClient, os.TempDir()+"/homebox") tRepos = repo.New(tClient, tbus, os.TempDir()+"/homebox")
tSvc = New(tRepos) tSvc = New(tRepos)
defer client.Close() defer client.Close()

View file

@ -0,0 +1,85 @@
// / Package eventbus provides an interface for event bus.
package eventbus
import (
"sync"
"github.com/google/uuid"
)
type Event string
const (
EventLabelMutation Event = "label.mutation"
EventLocationMutation Event = "location.mutation"
EventItemMutation Event = "item.mutation"
)
type GroupMutationEvent struct {
GID uuid.UUID
}
type eventData struct {
event Event
data any
}
type EventBus struct {
started bool
ch chan eventData
mu sync.RWMutex
subscribers map[Event][]func(any)
}
func New() *EventBus {
return &EventBus{
ch: make(chan eventData, 10),
subscribers: map[Event][]func(any){
EventLabelMutation: {},
EventLocationMutation: {},
EventItemMutation: {},
},
}
}
func (e *EventBus) Run() {
if e.started {
panic("event bus already started")
}
e.started = true
for event := range e.ch {
e.mu.RLock()
arr, ok := e.subscribers[event.event]
e.mu.RUnlock()
if !ok {
continue
}
for _, fn := range arr {
fn(event.data)
}
}
}
func (e *EventBus) Publish(event Event, data any) {
e.ch <- eventData{
event: event,
data: data,
}
}
func (e *EventBus) Subscribe(event Event, fn func(any)) {
e.mu.Lock()
defer e.mu.Unlock()
arr, ok := e.subscribers[event]
if !ok {
panic("event not found")
}
e.subscribers[event] = append(arr, fn)
}

View file

@ -6,6 +6,7 @@ import (
"os" "os"
"testing" "testing"
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/hay-kot/homebox/backend/internal/data/ent" "github.com/hay-kot/homebox/backend/internal/data/ent"
"github.com/hay-kot/homebox/backend/pkgs/faker" "github.com/hay-kot/homebox/backend/pkgs/faker"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
@ -13,6 +14,7 @@ import (
var ( var (
fk = faker.NewFaker() fk = faker.NewFaker()
tbus = eventbus.New()
tClient *ent.Client tClient *ent.Client
tRepos *AllRepos tRepos *AllRepos
@ -43,13 +45,15 @@ func TestMain(m *testing.M) {
log.Fatalf("failed opening connection to sqlite: %v", err) log.Fatalf("failed opening connection to sqlite: %v", err)
} }
go tbus.Run()
err = client.Schema.Create(context.Background()) err = client.Schema.Create(context.Background())
if err != nil { if err != nil {
log.Fatalf("failed creating schema resources: %v", err) log.Fatalf("failed creating schema resources: %v", err)
} }
tClient = client tClient = client
tRepos = New(tClient, os.TempDir()) tRepos = New(tClient, tbus, os.TempDir())
defer client.Close() defer client.Close()
bootstrap() bootstrap()

View file

@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/hay-kot/homebox/backend/internal/data/ent" "github.com/hay-kot/homebox/backend/internal/data/ent"
"github.com/hay-kot/homebox/backend/internal/data/ent/group" "github.com/hay-kot/homebox/backend/internal/data/ent/group"
"github.com/hay-kot/homebox/backend/internal/data/ent/item" "github.com/hay-kot/homebox/backend/internal/data/ent/item"
@ -18,6 +19,7 @@ import (
type ItemsRepository struct { type ItemsRepository struct {
db *ent.Client db *ent.Client
bus *eventbus.EventBus
} }
type ( type (
@ -266,6 +268,12 @@ func mapItemOut(item *ent.Item) ItemOut {
} }
} }
func (r *ItemsRepository) publishMutationEvent(GID uuid.UUID) {
if r.bus != nil {
r.bus.Publish(eventbus.EventItemMutation, eventbus.GroupMutationEvent{GID: GID})
}
}
func (e *ItemsRepository) getOne(ctx context.Context, where ...predicate.Item) (ItemOut, error) { func (e *ItemsRepository) getOne(ctx context.Context, where ...predicate.Item) (ItemOut, error) {
q := e.db.Item.Query().Where(where...) q := e.db.Item.Query().Where(where...)
@ -520,11 +528,18 @@ func (e *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCr
return ItemOut{}, err return ItemOut{}, err
} }
e.publishMutationEvent(gid)
return e.GetOne(ctx, result.ID) return e.GetOne(ctx, result.ID)
} }
func (e *ItemsRepository) Delete(ctx context.Context, id uuid.UUID) error { func (e *ItemsRepository) Delete(ctx context.Context, id uuid.UUID) error {
return e.db.Item.DeleteOneID(id).Exec(ctx) err := e.db.Item.DeleteOneID(id).Exec(ctx)
if err != nil {
return err
}
e.publishMutationEvent(id)
return nil
} }
func (e *ItemsRepository) DeleteByGroup(ctx context.Context, gid, id uuid.UUID) error { func (e *ItemsRepository) DeleteByGroup(ctx context.Context, gid, id uuid.UUID) error {
@ -534,6 +549,12 @@ func (e *ItemsRepository) DeleteByGroup(ctx context.Context, gid, id uuid.UUID)
item.ID(id), item.ID(id),
item.HasGroupWith(group.ID(gid)), item.HasGroupWith(group.ID(gid)),
).Exec(ctx) ).Exec(ctx)
if err != nil {
return err
}
e.publishMutationEvent(gid)
return err return err
} }
@ -649,6 +670,7 @@ func (e *ItemsRepository) UpdateByGroup(ctx context.Context, GID uuid.UUID, data
} }
} }
e.publishMutationEvent(GID)
return e.GetOne(ctx, data.ID) return e.GetOne(ctx, data.ID)
} }
@ -687,6 +709,7 @@ func (e *ItemsRepository) Patch(ctx context.Context, GID, ID uuid.UUID, data Ite
q.SetQuantity(*data.Quantity) q.SetQuantity(*data.Quantity)
} }
e.publishMutationEvent(GID)
return q.Exec(ctx) return q.Exec(ctx)
} }

View file

@ -39,7 +39,7 @@ func useItems(t *testing.T, len int) []ItemOut {
_ = tRepos.Items.Delete(context.Background(), item.ID) _ = tRepos.Items.Delete(context.Background(), item.ID)
} }
_ = tRepos.Locations.Delete(context.Background(), location.ID) _ = tRepos.Locations.delete(context.Background(), location.ID)
}) })
return items return items
@ -123,7 +123,7 @@ func TestItemsRepository_Create(t *testing.T) {
assert.NotEmpty(t, result.ID) assert.NotEmpty(t, result.ID)
// Cleanup - Also deletes item // Cleanup - Also deletes item
err = tRepos.Locations.Delete(context.Background(), location.ID) err = tRepos.Locations.delete(context.Background(), location.ID)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -147,7 +147,7 @@ func TestItemsRepository_Create_Location(t *testing.T) {
assert.Equal(t, location.ID, foundItem.Location.ID) assert.Equal(t, location.ID, foundItem.Location.ID)
// Cleanup - Also deletes item // Cleanup - Also deletes item
err = tRepos.Locations.Delete(context.Background(), location.ID) err = tRepos.Locations.delete(context.Background(), location.ID)
assert.NoError(t, err) assert.NoError(t, err)
} }

View file

@ -5,6 +5,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/hay-kot/homebox/backend/internal/data/ent" "github.com/hay-kot/homebox/backend/internal/data/ent"
"github.com/hay-kot/homebox/backend/internal/data/ent/group" "github.com/hay-kot/homebox/backend/internal/data/ent/group"
"github.com/hay-kot/homebox/backend/internal/data/ent/item" "github.com/hay-kot/homebox/backend/internal/data/ent/item"
@ -14,7 +15,9 @@ import (
type LabelRepository struct { type LabelRepository struct {
db *ent.Client db *ent.Client
bus *eventbus.EventBus
} }
type ( type (
LabelCreate struct { LabelCreate struct {
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
@ -65,6 +68,12 @@ func mapLabelOut(label *ent.Label) LabelOut {
} }
} }
func (r *LabelRepository) publishMutationEvent(GID uuid.UUID) {
if r.bus != nil {
r.bus.Publish(eventbus.EventLabelMutation, eventbus.GroupMutationEvent{GID: GID})
}
}
func (r *LabelRepository) getOne(ctx context.Context, where ...predicate.Label) (LabelOut, error) { func (r *LabelRepository) getOne(ctx context.Context, where ...predicate.Label) (LabelOut, error) {
return mapLabelOutErr(r.db.Label.Query(). return mapLabelOutErr(r.db.Label.Query().
Where(where...). Where(where...).
@ -105,6 +114,7 @@ func (r *LabelRepository) Create(ctx context.Context, groupdId uuid.UUID, data L
} }
label.Edges.Group = &ent.Group{ID: groupdId} // bootstrap group ID label.Edges.Group = &ent.Group{ID: groupdId} // bootstrap group ID
r.publishMutationEvent(groupdId)
return mapLabelOut(label), err return mapLabelOut(label), err
} }
@ -121,25 +131,19 @@ func (r *LabelRepository) update(ctx context.Context, data LabelUpdate, where ..
Save(ctx) Save(ctx)
} }
func (r *LabelRepository) Update(ctx context.Context, data LabelUpdate) (LabelOut, error) {
_, err := r.update(ctx, data, label.ID(data.ID))
if err != nil {
return LabelOut{}, err
}
return r.GetOne(ctx, data.ID)
}
func (r *LabelRepository) UpdateByGroup(ctx context.Context, GID uuid.UUID, data LabelUpdate) (LabelOut, error) { func (r *LabelRepository) UpdateByGroup(ctx context.Context, GID uuid.UUID, data LabelUpdate) (LabelOut, error) {
_, err := r.update(ctx, data, label.ID(data.ID), label.HasGroupWith(group.ID(GID))) _, err := r.update(ctx, data, label.ID(data.ID), label.HasGroupWith(group.ID(GID)))
if err != nil { if err != nil {
return LabelOut{}, err return LabelOut{}, err
} }
r.publishMutationEvent(GID)
return r.GetOne(ctx, data.ID) return r.GetOne(ctx, data.ID)
} }
func (r *LabelRepository) Delete(ctx context.Context, id uuid.UUID) error { // delete removes the label from the database. This should only be used when
// the label's ownership is already confirmed/validated.
func (r *LabelRepository) delete(ctx context.Context, id uuid.UUID) error {
return r.db.Label.DeleteOneID(id).Exec(ctx) return r.db.Label.DeleteOneID(id).Exec(ctx)
} }
@ -149,6 +153,11 @@ func (r *LabelRepository) DeleteByGroup(ctx context.Context, gid, id uuid.UUID)
label.ID(id), label.ID(id),
label.HasGroupWith(group.ID(gid)), label.HasGroupWith(group.ID(gid)),
).Exec(ctx) ).Exec(ctx)
if err != nil {
return err return err
}
r.publishMutationEvent(gid)
return nil
} }

View file

@ -28,7 +28,7 @@ func useLabels(t *testing.T, len int) []LabelOut {
t.Cleanup(func() { t.Cleanup(func() {
for _, item := range labels { for _, item := range labels {
_ = tRepos.Labels.Delete(context.Background(), item.ID) _ = tRepos.Labels.delete(context.Background(), item.ID)
} }
}) })
@ -62,7 +62,7 @@ func TestLabelRepository_Create(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, loc.ID, foundLoc.ID) assert.Equal(t, loc.ID, foundLoc.ID)
err = tRepos.Labels.Delete(context.Background(), loc.ID) err = tRepos.Labels.delete(context.Background(), loc.ID)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -76,7 +76,7 @@ func TestLabelRepository_Update(t *testing.T) {
Description: fk.Str(100), Description: fk.Str(100),
} }
update, err := tRepos.Labels.Update(context.Background(), updateData) update, err := tRepos.Labels.UpdateByGroup(context.Background(), tGroup.ID, updateData)
assert.NoError(t, err) assert.NoError(t, err)
foundLoc, err := tRepos.Labels.GetOne(context.Background(), loc.ID) foundLoc, err := tRepos.Labels.GetOne(context.Background(), loc.ID)
@ -86,7 +86,7 @@ func TestLabelRepository_Update(t *testing.T) {
assert.Equal(t, update.Name, foundLoc.Name) assert.Equal(t, update.Name, foundLoc.Name)
assert.Equal(t, update.Description, foundLoc.Description) assert.Equal(t, update.Description, foundLoc.Description)
err = tRepos.Labels.Delete(context.Background(), loc.ID) err = tRepos.Labels.delete(context.Background(), loc.ID)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -94,7 +94,7 @@ func TestLabelRepository_Delete(t *testing.T) {
loc, err := tRepos.Labels.Create(context.Background(), tGroup.ID, labelFactory()) loc, err := tRepos.Labels.Create(context.Background(), tGroup.ID, labelFactory())
assert.NoError(t, err) assert.NoError(t, err)
err = tRepos.Labels.Delete(context.Background(), loc.ID) err = tRepos.Labels.delete(context.Background(), loc.ID)
assert.NoError(t, err) assert.NoError(t, err)
_, err = tRepos.Labels.GetOne(context.Background(), loc.ID) _, err = tRepos.Labels.GetOne(context.Background(), loc.ID)

View file

@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/hay-kot/homebox/backend/internal/data/ent" "github.com/hay-kot/homebox/backend/internal/data/ent"
"github.com/hay-kot/homebox/backend/internal/data/ent/group" "github.com/hay-kot/homebox/backend/internal/data/ent/group"
"github.com/hay-kot/homebox/backend/internal/data/ent/item" "github.com/hay-kot/homebox/backend/internal/data/ent/item"
@ -15,6 +16,7 @@ import (
type LocationRepository struct { type LocationRepository struct {
db *ent.Client db *ent.Client
bus *eventbus.EventBus
} }
type ( type (
@ -90,6 +92,12 @@ func mapLocationOut(location *ent.Location) LocationOut {
} }
} }
func (r *LocationRepository) publishMutationEvent(GID uuid.UUID) {
if r.bus != nil {
r.bus.Publish(eventbus.EventLocationMutation, eventbus.GroupMutationEvent{GID: GID})
}
}
type LocationQuery struct { type LocationQuery struct {
FilterChildren bool `json:"filterChildren" schema:"filterChildren"` FilterChildren bool `json:"filterChildren" schema:"filterChildren"`
} }
@ -190,6 +198,7 @@ func (r *LocationRepository) Create(ctx context.Context, GID uuid.UUID, data Loc
} }
location.Edges.Group = &ent.Group{ID: GID} // bootstrap group ID location.Edges.Group = &ent.Group{ID: GID} // bootstrap group ID
r.publishMutationEvent(GID)
return mapLocationOut(location), nil return mapLocationOut(location), nil
} }
@ -213,20 +222,29 @@ func (r *LocationRepository) update(ctx context.Context, data LocationUpdate, wh
return r.Get(ctx, data.ID) return r.Get(ctx, data.ID)
} }
func (r *LocationRepository) Update(ctx context.Context, data LocationUpdate) (LocationOut, error) {
return r.update(ctx, data, location.ID(data.ID))
}
func (r *LocationRepository) UpdateByGroup(ctx context.Context, GID, ID uuid.UUID, data LocationUpdate) (LocationOut, error) { func (r *LocationRepository) UpdateByGroup(ctx context.Context, GID, ID uuid.UUID, data LocationUpdate) (LocationOut, error) {
return r.update(ctx, data, location.ID(ID), location.HasGroupWith(group.ID(GID))) v, err := r.update(ctx, data, location.ID(ID), location.HasGroupWith(group.ID(GID)))
if err != nil {
return LocationOut{}, err
}
r.publishMutationEvent(GID)
return v, err
} }
func (r *LocationRepository) Delete(ctx context.Context, ID uuid.UUID) error { // delete should only be used after checking that the location is owned by the
// group. Otherwise, use DeleteByGroup
func (r *LocationRepository) delete(ctx context.Context, ID uuid.UUID) error {
return r.db.Location.DeleteOneID(ID).Exec(ctx) return r.db.Location.DeleteOneID(ID).Exec(ctx)
} }
func (r *LocationRepository) DeleteByGroup(ctx context.Context, GID, ID uuid.UUID) error { func (r *LocationRepository) DeleteByGroup(ctx context.Context, GID, ID uuid.UUID) error {
_, err := r.db.Location.Delete().Where(location.ID(ID), location.HasGroupWith(group.ID(GID))).Exec(ctx) _, err := r.db.Location.Delete().Where(location.ID(ID), location.HasGroupWith(group.ID(GID))).Exec(ctx)
if err != nil {
return err
}
r.publishMutationEvent(GID)
return err return err
} }

View file

@ -30,7 +30,7 @@ func useLocations(t *testing.T, len int) []LocationOut {
t.Cleanup(func() { t.Cleanup(func() {
for _, loc := range out { for _, loc := range out {
err := tRepos.Locations.Delete(context.Background(), loc.ID) err := tRepos.Locations.delete(context.Background(), loc.ID)
if err != nil { if err != nil {
assert.True(t, ent.IsNotFound(err)) assert.True(t, ent.IsNotFound(err))
} }
@ -49,7 +49,7 @@ func TestLocationRepository_Get(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, loc.ID, foundLoc.ID) assert.Equal(t, loc.ID, foundLoc.ID)
err = tRepos.Locations.Delete(context.Background(), loc.ID) err = tRepos.Locations.delete(context.Background(), loc.ID)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -83,7 +83,7 @@ func TestLocationRepository_Create(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, loc.ID, foundLoc.ID) assert.Equal(t, loc.ID, foundLoc.ID)
err = tRepos.Locations.Delete(context.Background(), loc.ID) err = tRepos.Locations.delete(context.Background(), loc.ID)
assert.NoError(t, err) assert.NoError(t, err)
} }
@ -96,7 +96,7 @@ func TestLocationRepository_Update(t *testing.T) {
Description: fk.Str(100), Description: fk.Str(100),
} }
update, err := tRepos.Locations.Update(context.Background(), updateData) update, err := tRepos.Locations.UpdateByGroup(context.Background(), tGroup.ID, updateData.ID, updateData)
assert.NoError(t, err) assert.NoError(t, err)
foundLoc, err := tRepos.Locations.Get(context.Background(), loc.ID) foundLoc, err := tRepos.Locations.Get(context.Background(), loc.ID)
@ -106,14 +106,14 @@ func TestLocationRepository_Update(t *testing.T) {
assert.Equal(t, update.Name, foundLoc.Name) assert.Equal(t, update.Name, foundLoc.Name)
assert.Equal(t, update.Description, foundLoc.Description) assert.Equal(t, update.Description, foundLoc.Description)
err = tRepos.Locations.Delete(context.Background(), loc.ID) err = tRepos.Locations.delete(context.Background(), loc.ID)
assert.NoError(t, err) assert.NoError(t, err)
} }
func TestLocationRepository_Delete(t *testing.T) { func TestLocationRepository_Delete(t *testing.T) {
loc := useLocations(t, 1)[0] loc := useLocations(t, 1)[0]
err := tRepos.Locations.Delete(context.Background(), loc.ID) err := tRepos.Locations.delete(context.Background(), loc.ID)
assert.NoError(t, err) assert.NoError(t, err)
_, err = tRepos.Locations.Get(context.Background(), loc.ID) _, err = tRepos.Locations.Get(context.Background(), loc.ID)

View file

@ -1,6 +1,9 @@
package repo package repo
import "github.com/hay-kot/homebox/backend/internal/data/ent" import (
"github.com/hay-kot/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/hay-kot/homebox/backend/internal/data/ent"
)
// AllRepos is a container for all the repository interfaces // AllRepos is a container for all the repository interfaces
type AllRepos struct { type AllRepos struct {
@ -16,14 +19,14 @@ type AllRepos struct {
Notifiers *NotifierRepository Notifiers *NotifierRepository
} }
func New(db *ent.Client, root string) *AllRepos { func New(db *ent.Client, bus *eventbus.EventBus, root string) *AllRepos {
return &AllRepos{ return &AllRepos{
Users: &UserRepository{db}, Users: &UserRepository{db},
AuthTokens: &TokenRepository{db}, AuthTokens: &TokenRepository{db},
Groups: NewGroupRepository(db), Groups: NewGroupRepository(db),
Locations: &LocationRepository{db}, Locations: &LocationRepository{db, bus},
Labels: &LabelRepository{db}, Labels: &LabelRepository{db, bus},
Items: &ItemsRepository{db}, Items: &ItemsRepository{db, bus},
Docs: &DocumentRepository{db, root}, Docs: &DocumentRepository{db, root},
Attachments: &AttachmentRepo{db}, Attachments: &AttachmentRepo{db},
MaintEntry: &MaintenanceEntryRepository{db}, MaintEntry: &MaintenanceEntryRepository{db},

View file

@ -1,6 +1,9 @@
package mid package mid
import ( import (
"bufio"
"errors"
"net"
"net/http" "net/http"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
@ -17,6 +20,14 @@ func (s *spy) WriteHeader(status int) {
s.ResponseWriter.WriteHeader(status) s.ResponseWriter.WriteHeader(status)
} }
func (s *spy) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := s.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, errors.New("response writer does not support hijacking")
}
return hj.Hijack()
}
func Logger(l zerolog.Logger) func(http.Handler) http.Handler { func Logger(l zerolog.Logger) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler { return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View file

@ -86,8 +86,6 @@
importRef.value?.click(); importRef.value?.click();
} }
const eventBus = useEventBus();
async function submitCsvFile() { async function submitCsvFile() {
if (!importCsv.value) { if (!importCsv.value) {
toast.error("Please select a file to import."); toast.error("Please select a file to import.");
@ -111,8 +109,6 @@
importRef.value.value = ""; importRef.value.value = "";
} }
eventBus.emit(EventTypes.InvalidStores);
toast.success("Import successful!"); toast.success("Import successful!");
} }
</script> </script>

View file

@ -15,7 +15,7 @@
<script setup lang="ts"> <script setup lang="ts">
type Props = { type Props = {
modelValue: string; modelValue: string;
placeholder: string; placeholder?: string;
label: string; label: string;
}; };

View file

@ -1,14 +1,14 @@
<template> <template>
<BaseModal v-model="modal"> <BaseModal v-model="modal">
<template #title> Create Item </template> <template #title> Create Item </template>
<div @keyup="keySubmit"> <form @submit.prevent="create()">
<LocationSelector v-model="form.location" /> <LocationSelector v-model="form.location" />
<FormTextField ref="nameInput" v-model="form.name" :trigger-focus="focused" :autofocus="true" label="Item Name" /> <FormTextField ref="nameInput" v-model="form.name" :trigger-focus="focused" :autofocus="true" label="Item Name" />
<FormTextArea v-model="form.description" label="Item Description" /> <FormTextArea v-model="form.description" label="Item Description" />
<FormMultiselect v-model="form.labels" label="Labels" :items="labels ?? []" /> <FormMultiselect v-model="form.labels" label="Labels" :items="labels ?? []" />
<div class="modal-action"> <div class="modal-action">
<div class="flex justify-center"> <div class="flex justify-center">
<BaseButton class="rounded-r-none" :loading="loading" @click="create(true)"> <BaseButton class="rounded-r-none" :loading="loading" type="submit">
<template #icon> <template #icon>
<Icon name="mdi-package-variant" class="swap-off h-5 w-5" /> <Icon name="mdi-package-variant" class="swap-off h-5 w-5" />
<Icon name="mdi-package-variant-closed" class="swap-on h-5 w-5" /> <Icon name="mdi-package-variant-closed" class="swap-on h-5 w-5" />
@ -21,13 +21,13 @@
</label> </label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-64"> <ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-64">
<li> <li>
<button type="button" @click.prevent="create(false)">Create and Add Another</button> <button type="button" @click="create(false)">Create and Add Another</button>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div> </form>
<p class="text-sm text-center mt-4"> <p class="text-sm text-center mt-4">
use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another
</p> </p>
@ -84,6 +84,8 @@
labels: [] as LabelOut[], labels: [] as LabelOut[],
}); });
const { shift } = useMagicKeys();
whenever( whenever(
() => modal.value, () => modal.value,
() => { () => {
@ -102,11 +104,15 @@
} }
); );
async function create(close = false) { async function create(close = true) {
if (!form.location) { if (!form.location) {
return; return;
} }
if (shift.value) {
close = false;
}
const out: ItemCreate = { const out: ItemCreate = {
parentId: null, parentId: null,
name: form.name, name: form.name,
@ -135,18 +141,4 @@
navigateTo(`/item/${data.id}`); navigateTo(`/item/${data.id}`);
} }
} }
async function keySubmit(e: KeyboardEvent) {
// Shift + Enter
if (e.shiftKey && e.key === "Enter") {
console.log("Shift + Enter");
e.preventDefault();
await create(false);
focused.value = true;
} else if (e.key === "Enter") {
e.preventDefault();
console.log("Enter");
await create(true);
}
}
</script> </script>

View file

@ -1,7 +1,7 @@
<template> <template>
<BaseModal v-model="modal"> <BaseModal v-model="modal">
<template #title> Create Label </template> <template #title> Create Label </template>
<div @keyup="keySubmit"> <form @submit.prevent="create()">
<FormTextField <FormTextField
ref="locationNameRef" ref="locationNameRef"
v-model="form.name" v-model="form.name"
@ -12,24 +12,22 @@
<FormTextArea v-model="form.description" label="Label Description" /> <FormTextArea v-model="form.description" label="Label Description" />
<div class="modal-action"> <div class="modal-action">
<div class="flex justify-center"> <div class="flex justify-center">
<BaseButton class="rounded-r-none" type="submit" :loading="loading" @click.prevent="create(true)"> <BaseButton class="rounded-r-none" :loading="loading" type="submit"> Create </BaseButton>
Create
</BaseButton>
<div class="dropdown dropdown-top"> <div class="dropdown dropdown-top">
<label tabindex="0" class="btn rounded-l-none rounded-r-xl"> <label tabindex="0" class="btn rounded-l-none rounded-r-xl">
<Icon class="h-5 w-5" name="mdi-chevron-down" /> <Icon class="h-5 w-5" name="mdi-chevron-down" />
</label> </label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-64"> <ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-64">
<li> <li>
<button @click.prevent="create(false)">Create and Add Another</button> <button type="button" @click="create(false)">Create and Add Another</button>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div> </form>
<p class="text-sm text-center mt-4"> <p class="text-sm text-center mt-4">
use <kbd class="kbd kbd-xs">Shift</kbd> + <kdb class="kbd kbd-xs"> Enter </kdb> to create and add another use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another
</p> </p>
</BaseModal> </BaseModal>
</template> </template>
@ -69,7 +67,13 @@
const api = useUserApi(); const api = useUserApi();
const toast = useNotifier(); const toast = useNotifier();
async function create(close: boolean) { const { shift } = useMagicKeys();
async function create(close = true) {
if (shift.value) {
close = false;
}
const { error, data } = await api.labels.create(form); const { error, data } = await api.labels.create(form);
if (error) { if (error) {
toast.error("Couldn't create label"); toast.error("Couldn't create label");
@ -84,16 +88,4 @@
navigateTo(`/label/${data.id}`); navigateTo(`/label/${data.id}`);
} }
} }
async function keySubmit(e: KeyboardEvent) {
// Shift + Enter
if (e.shiftKey && e.key === "Enter") {
e.preventDefault();
await create(false);
focused.value = true;
} else if (e.key === "Enter") {
e.preventDefault();
await create(true);
}
}
</script> </script>

View file

@ -1,7 +1,7 @@
<template> <template>
<BaseModal v-model="modal"> <BaseModal v-model="modal">
<template #title> Create Location </template> <template #title> Create Location </template>
<div @keyup="keySubmit"> <form @submit.prevent="create()">
<FormTextField <FormTextField
ref="locationNameRef" ref="locationNameRef"
v-model="form.name" v-model="form.name"
@ -13,22 +13,20 @@
<LocationSelector v-model="form.parent" /> <LocationSelector v-model="form.parent" />
<div class="modal-action"> <div class="modal-action">
<div class="flex justify-center"> <div class="flex justify-center">
<BaseButton class="rounded-r-none" type="submit" :loading="loading" @click="create(true)"> <BaseButton class="rounded-r-none" type="submit" :loading="loading"> Create </BaseButton>
Create
</BaseButton>
<div class="dropdown dropdown-top"> <div class="dropdown dropdown-top">
<label tabindex="0" class="btn rounded-l-none rounded-r-xl"> <label tabindex="0" class="btn rounded-l-none rounded-r-xl">
<Icon class="h-5 w-5" name="mdi-chevron-down" /> <Icon class="h-5 w-5" name="mdi-chevron-down" />
</label> </label>
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-64"> <ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-64">
<li> <li>
<button @click.prevent="create(false)">Create and Add Another</button> <button type="button" @click="create(false)">Create and Add Another</button>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div> </form>
<p class="text-sm text-center mt-4"> <p class="text-sm text-center mt-4">
use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another use <kbd class="kbd kbd-xs">Shift</kbd> + <kbd class="kbd kbd-xs"> Enter </kbd> to create and add another
</p> </p>
@ -71,9 +69,15 @@
const api = useUserApi(); const api = useUserApi();
const toast = useNotifier(); const toast = useNotifier();
async function create(close: boolean) { const { shift } = useMagicKeys();
async function create(close = true) {
loading.value = true; loading.value = true;
if (shift.value) {
close = false;
}
const { data, error } = await api.locations.create({ const { data, error } = await api.locations.create({
name: form.name, name: form.name,
description: form.description, description: form.description,
@ -94,16 +98,4 @@
navigateTo(`/location/${data.id}`); navigateTo(`/location/${data.id}`);
} }
} }
async function keySubmit(e: KeyboardEvent) {
// Shift + Enter
if (e.shiftKey && e.key === "Enter") {
e.preventDefault();
await create(false);
focused.value = true;
} else if (e.key === "Enter") {
e.preventDefault();
await create(true);
}
}
</script> </script>

View file

@ -1,38 +0,0 @@
export enum EventTypes {
// ClearStores event is used to inform the stores that _all_ the data they are using
// is now out of date and they should refresh - This is used when the user makes large
// changes to the data such as bulk actions or importing a CSV file
InvalidStores,
}
export type EventFn = () => void;
export interface IEventBus {
on(event: EventTypes, fn: EventFn, key: string): void;
off(event: EventTypes, key: string): void;
emit(event: EventTypes): void;
}
class EventBus implements IEventBus {
private listeners: Record<EventTypes, Record<string, EventFn>> = {
[EventTypes.InvalidStores]: {},
};
on(event: EventTypes, fn: EventFn, key: string): void {
this.listeners[event][key] = fn;
}
off(event: EventTypes, key: string): void {
delete this.listeners[event][key];
}
emit(event: EventTypes): void {
Object.values(this.listeners[event]).forEach(fn => fn());
}
}
const bus = new EventBus();
export function useEventBus(): IEventBus {
return bus;
}

View file

@ -0,0 +1,78 @@
export enum ServerEvent {
LocationMutation = "location.mutation",
ItemMutation = "item.mutation",
LabelMutation = "label.mutation",
}
export type EventMessage = {
event: ServerEvent;
};
let socket: WebSocket | null = null;
const listeners = new Map<ServerEvent, (() => void)[]>();
function connect(onmessage: (m: EventMessage) => void) {
const ws = new WebSocket(`ws://${window.location.host}/api/v1/ws/events`);
ws.onopen = () => {
console.debug("connected to server");
};
ws.onclose = () => {
console.debug("disconnected from server");
setTimeout(() => {
connect(onmessage);
}, 3000);
};
ws.onerror = err => {
console.error("websocket error", err);
};
const thorttled = new Map<ServerEvent, any>();
thorttled.set(ServerEvent.LocationMutation, useThrottleFn(onmessage, 1000));
thorttled.set(ServerEvent.ItemMutation, useThrottleFn(onmessage, 1000));
thorttled.set(ServerEvent.LabelMutation, useThrottleFn(onmessage, 1000));
ws.onmessage = msg => {
const pm = JSON.parse(msg.data);
const fn = thorttled.get(pm.event);
if (fn) {
fn(pm);
}
};
socket = ws;
}
export function onServerEvent(event: ServerEvent, callback: () => void) {
if (socket === null) {
connect(e => {
console.debug("received event", e);
listeners.get(e.event)?.forEach(c => c());
});
}
onMounted(() => {
if (!listeners.has(event)) {
listeners.set(event, []);
}
listeners.get(event)?.push(callback);
});
onUnmounted(() => {
const got = listeners.get(event);
if (got) {
listeners.set(
event,
got.filter(c => c !== callback)
);
}
if (listeners.get(event)?.length === 0) {
listeners.delete(event);
}
});
}

View file

@ -174,52 +174,24 @@
}, },
]; ];
function isMutation(method: string | undefined) {
return method === "POST" || method === "PUT" || method === "DELETE";
}
function isSuccess(status: number) {
return status >= 200 && status < 300;
}
const labelStore = useLabelStore(); const labelStore = useLabelStore();
const reLabel = /\/api\/v1\/labels\/.*/gm;
const rmLabelStoreObserver = defineObserver("labelStore", {
handler: (resp, req) => {
if (isMutation(req?.method) && isSuccess(resp.status) && resp.url.match(reLabel)) {
labelStore.refresh();
}
console.debug("labelStore handler called by observer");
},
});
const locationStore = useLocationStore(); const locationStore = useLocationStore();
const reLocation = /\/api\/v1\/locations\/.*/gm;
const rmLocationStoreObserver = defineObserver("locationStore", {
handler: (resp, req) => {
if (isMutation(req?.method) && isSuccess(resp.status) && resp.url.match(reLocation)) {
locationStore.refreshChildren();
locationStore.refreshParents();
}
console.debug("locationStore handler called by observer"); onServerEvent(ServerEvent.LabelMutation, () => {
}, labelStore.refresh();
}); });
const eventBus = useEventBus(); onServerEvent(ServerEvent.LocationMutation, () => {
eventBus.on(
EventTypes.InvalidStores,
() => {
labelStore.refresh();
locationStore.refreshChildren(); locationStore.refreshChildren();
locationStore.refreshParents(); locationStore.refreshParents();
}, });
"stores"
);
onUnmounted(() => { onServerEvent(ServerEvent.ItemMutation, () => {
rmLabelStoreObserver(); // item mutations can affect locations counts
rmLocationStoreObserver(); // so we need to refresh those as well
eventBus.off(EventTypes.InvalidStores, "stores"); locationStore.refreshChildren();
locationStore.refreshParents();
}); });
const authCtx = useAuthContext(); const authCtx = useAuthContext();

View file

@ -3,11 +3,12 @@ import { defineNuxtConfig } from "nuxt/config";
// https://v3.nuxtjs.org/api/configuration/nuxt.config // https://v3.nuxtjs.org/api/configuration/nuxt.config
export default defineNuxtConfig({ export default defineNuxtConfig({
ssr: false, ssr: false,
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt", "@vueuse/nuxt", "@vite-pwa/nuxt"], modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt", "@vueuse/nuxt", "@vite-pwa/nuxt", "./nuxt.proxyoverride.ts"],
nitro: { nitro: {
devProxy: { devProxy: {
"/api": { "/api": {
target: "http://localhost:7745/api", target: "http://localhost:7745/api",
ws: true,
changeOrigin: true, changeOrigin: true,
}, },
}, },

View file

@ -0,0 +1,46 @@
// https://gist.github.com/ucw/67f7291c64777fb24341e8eae72bcd24
import { IncomingMessage } from "http";
import internal from "stream";
import { defineNuxtModule, logger } from "@nuxt/kit";
// eslint-disable-next-line
import { createProxyServer } from "http-proxy";
export default defineNuxtModule({
defaults: {
target: "ws://localhost:7745",
path: "/api/v1/ws",
},
meta: {
configKey: "websocketProxy",
name: "Websocket proxy",
},
setup(resolvedOptions, nuxt) {
if (!nuxt.options.dev || !resolvedOptions.target) {
return;
}
nuxt.hook("listen", server => {
const proxy = createProxyServer({
ws: true,
secure: false,
changeOrigin: true,
target: resolvedOptions.target,
});
const proxyFn = (req: IncomingMessage, socket: internal.Duplex, head: Buffer) => {
if (req.url && req.url.startsWith(resolvedOptions.path)) {
proxy.ws(req, socket, head);
}
};
server.on("upgrade", proxyFn);
nuxt.hook("close", () => {
server.off("upgrade", proxyFn);
proxy.close();
});
logger.info(`Websocket dev proxy started on ${resolvedOptions.path}`);
});
},
});

View file

@ -47,6 +47,7 @@
"chart.js": "^4.0.1", "chart.js": "^4.0.1",
"daisyui": "^2.24.0", "daisyui": "^2.24.0",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
"h3": "^1.7.1",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"pinia": "^2.0.21", "pinia": "^2.0.21",
"postcss": "^8.4.16", "postcss": "^8.4.16",

View file

@ -1,7 +1,7 @@
import { UserClient } from "~~/lib/api/user"; import { UserClient } from "~~/lib/api/user";
export function itemsTable(api: UserClient) { export function itemsTable(api: UserClient) {
const { data: items } = useAsyncData(async () => { const { data: items, refresh } = useAsyncData(async () => {
const { data } = await api.items.getAll({ const { data } = await api.items.getAll({
page: 1, page: 1,
pageSize: 5, pageSize: 5,
@ -10,6 +10,11 @@ export function itemsTable(api: UserClient) {
return data.items; return data.items;
}); });
onServerEvent(ServerEvent.ItemMutation, () => {
console.log("item mutation");
refresh();
});
return computed(() => { return computed(() => {
return { return {
items: items.value || [], items: items.value || [],

View file

@ -288,8 +288,9 @@
<BaseModal v-model="passwordChange.dialog"> <BaseModal v-model="passwordChange.dialog">
<template #title> Change Password </template> <template #title> Change Password </template>
<FormPassword v-model="passwordChange.current" label="Current Password" /> <form @submit.prevent="changePassword">
<FormPassword v-model="passwordChange.new" label="New Password" /> <FormPassword v-model="passwordChange.current" label="Current Password" placeholder="" />
<FormPassword v-model="passwordChange.new" label="New Password" placeholder="" />
<PasswordScore v-model:valid="passwordChange.isValid" :password="passwordChange.new" /> <PasswordScore v-model:valid="passwordChange.isValid" :password="passwordChange.new" />
<div class="flex"> <div class="flex">
@ -297,11 +298,12 @@
class="ml-auto" class="ml-auto"
:loading="passwordChange.loading" :loading="passwordChange.loading"
:disabled="!passwordChange.isValid" :disabled="!passwordChange.isValid"
@click="changePassword" type="submit"
> >
Submit Submit
</BaseButton> </BaseButton>
</div> </div>
</form>
</BaseModal> </BaseModal>
<BaseModal v-model="notifierDialog"> <BaseModal v-model="notifierDialog">

View file

@ -44,6 +44,9 @@ dependencies:
dompurify: dompurify:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
h3:
specifier: ^1.7.1
version: 1.7.1
markdown-it: markdown-it:
specifier: ^13.0.1 specifier: ^13.0.1
version: 13.0.1 version: 13.0.1