diff --git a/cmd/server/main.go b/cmd/server/main.go index ea49ab2..f87a18e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -37,7 +37,11 @@ func main() { } ctx := context.Background() - macChannel, macDeamon := status.NewDaemon(ctx, factoryStorage.StatusIterator()) + macChannel, macDeamon := status.NewDaemon( + ctx, + factoryStorage.StatusIterator(), + factoryStorage.StatusTx(), + ) // CORS (Cross-Origin Resource Sharing) middleware that enables public // access to GET/OPTIONS requests. Used to expose APIs to XHR consumers in @@ -120,6 +124,7 @@ func main() { "/update", api.UpdateStatus(macChannel), ) + r.Get("/status", api.Status(factoryStorage.StatusTx())) }) r.With(lsmiddleware.ApiAuth(*config, false)).Get("/who", handlers.Who()) diff --git a/pkg/services/handlers/api/v1/api.go b/pkg/services/handlers/api/v1/api.go index e9c69e3..65532e3 100644 --- a/pkg/services/handlers/api/v1/api.go +++ b/pkg/services/handlers/api/v1/api.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "fmt" @@ -285,6 +286,40 @@ func UpdateStatus(ch chan<- []net.HardwareAddr) http.HandlerFunc { } } +func Status(counters storage.StatusTx) http.HandlerFunc { + var response struct { + Online int `json:"online"` + Unknown int `json:"unknown"` + } + + return func(w http.ResponseWriter, r *http.Request) { + err := counters.DevicesStatus( + r.Context(), + func(ctx context.Context, s storage.Status) error { + online, err := s.OnlineUsers(ctx) + if err != nil { + return fmt.Errorf("failed to read online users: %w", err) + } + + unknown, err := s.UnknownDevices(ctx) + if err != nil { + return fmt.Errorf("failed to read unknown devices: %w", err) + } + + response.Online = online + response.Unknown = unknown + return nil + }, + ) + if err != nil { + internalServerError(w) + return + } + + gores.JSONIndent(w, http.StatusOK, response, defaultPrefix, defaultIndent) + } +} + func internalServerError(w http.ResponseWriter) { result.JSONError(w, &result.JSONErrorBody{ Message: "ooops! things are not going that great after all", diff --git a/pkg/services/status/status.go b/pkg/services/status/status.go index 548f367..3c0d7c0 100644 --- a/pkg/services/status/status.go +++ b/pkg/services/status/status.go @@ -14,7 +14,9 @@ type Daemon func() // NewDeamon returns channel for communicating with deamon and deamon // to be run in the background in the separate gourtine. -func NewDaemon(ctx context.Context, iter storage.StatusIterator) (chan<- []net.HardwareAddr, Daemon) { +func NewDaemon(ctx context.Context, + iter storage.StatusIterator, counters storage.StatusTx, +) (chan<- []net.HardwareAddr, Daemon) { ch := make(chan []net.HardwareAddr) daemon := func() { @@ -33,7 +35,11 @@ func NewDaemon(ctx context.Context, iter storage.StatusIterator) (chan<- []net.H macs = newMacs case <-ticker.C: // Update users every minute with newest mac addresses // Update online status for every user in db - err := storage.UpdateStatuses(ctx, macs, iter) + err := storage.UpdateStatuses(ctx, storage.UpdateStatusesArgs{ + Addresses: macs, + Iter: iter, + Counters: counters, + }) if err != nil { log.Println("Failed to update statuses, reason: ", err.Error()) continue diff --git a/pkg/storage/memory/memory.go b/pkg/storage/memory/memory.go index 85adaf5..88d1eee 100644 --- a/pkg/storage/memory/memory.go +++ b/pkg/storage/memory/memory.go @@ -11,6 +11,7 @@ import ( bolt "go.etcd.io/bbolt" "github.com/hakierspejs/long-season/pkg/models" + "github.com/hakierspejs/long-season/pkg/storage" serrors "github.com/hakierspejs/long-season/pkg/storage/errors" ) @@ -25,9 +26,10 @@ const ( // Factory implements storage.Factory interface for // bolt database. type Factory struct { - users *UsersStorage - devices *DevicesStorage - statusIter *StatusIterator + users *UsersStorage + devices *DevicesStorage + statusIter *StatusIterator + statusStorageTx *StatusStorageTx } // Users returns storage interface for manipulating @@ -46,6 +48,13 @@ func (f Factory) StatusIterator() *StatusIterator { return f.statusIter } +// StatusTx returns storage interface for +// reading and writing information about numbers +// of online users and unkown devices. +func (f Factory) StatusTx() *StatusStorageTx { + return f.statusStorageTx +} + // New returns pointer to new memory storage // Factory. func New(db *bolt.DB) (*Factory, error) { @@ -68,9 +77,10 @@ func New(db *bolt.DB) (*Factory, error) { } return &Factory{ - users: &UsersStorage{db}, - devices: &DevicesStorage{db}, - statusIter: &StatusIterator{db}, + users: &UsersStorage{db}, + devices: &DevicesStorage{db}, + statusIter: &StatusIterator{db}, + statusStorageTx: &StatusStorageTx{db}, }, nil } @@ -709,3 +719,100 @@ func (s *StatusIterator) ForEachUpdate( }) }) } + +const ( + onlineUsersCounter = "ls::users::online::counter" + unknownDevicesCounter = "ls::devices::unknown::counter" +) + +// status implements storage.Status interface. +// +// status is able to perform multiple operation, but only in +// single transaction, because it holds pointer to bolt.Tx instead +// of pointer to bolt.DB as other types in this package. +type status struct { + tx *bolt.Tx +} + +// OnlineUsers returns number of people being currently online. +func (s *status) OnlineUsers(ctx context.Context) (int, error) { + b, err := s.tx.CreateBucketIfNotExists([]byte(countersBucket)) + if err != nil { + return 0, fmt.Errorf("failed to create %s bucket: %w", countersBucket, err) + } + + onlineUsers := b.Get([]byte(onlineUsersCounter)) + if onlineUsers == nil { + return 0, fmt.Errorf("failed to retrieve online users counter") + } + + parsedOnlineUsers, err := strconv.Atoi(string(onlineUsers)) + if err != nil { + return 0, fmt.Errorf( + "failed to parse slice bytes: %s into integer: %w", + onlineUsers, err, + ) + } + + return parsedOnlineUsers, nil +} + +// SetOnlineUsers ovewrites number of people being currently online. +func (s *status) SetOnlineUsers(ctx context.Context, number int) error { + b, err := s.tx.CreateBucketIfNotExists([]byte(countersBucket)) + if err != nil { + return fmt.Errorf("failed to create %s bucket: %w", countersBucket, err) + } + + return b.Put([]byte(onlineUsersCounter), []byte(strconv.Itoa(number))) +} + +// UnknownDevices returns number of unknown devices connected to the network. +func (s *status) UnknownDevices(ctx context.Context) (int, error) { + b, err := s.tx.CreateBucketIfNotExists([]byte(countersBucket)) + if err != nil { + return 0, fmt.Errorf("failed to create %s bucket: %w", countersBucket, err) + } + + unknownDevices := b.Get([]byte(unknownDevicesCounter)) + if unknownDevices == nil { + return 0, fmt.Errorf("failed to retrieve unknown devices counter") + } + + parsedUnknownDevices, err := strconv.Atoi(string(unknownDevices)) + if err != nil { + return 0, fmt.Errorf( + "failed to parse slice bytes: %s into integer: %w", + unknownDevices, err, + ) + } + + return parsedUnknownDevices, nil +} + +// SetUnknownDevices overwrites number of unknown devices connected to the network. +func (s *status) SetUnknownDevices(ctx context.Context, number int) error { + b, err := s.tx.CreateBucketIfNotExists([]byte(countersBucket)) + if err != nil { + return fmt.Errorf("failed to create %s bucket: %w", countersBucket, err) + } + + return b.Put([]byte(unknownDevicesCounter), []byte(strconv.Itoa(number))) +} + +// StatusStorageTx implements storage.StatusTx interface. +type StatusStorageTx struct { + db *bolt.DB +} + +// DevicesStatus accepts function that manipulates number of +// unknown devices and online users in single safe transaction. +func (s *StatusStorageTx) DevicesStatus( + ctx context.Context, + f func(context.Context, storage.Status) error, +) error { + return s.db.Update(func(tx *bolt.Tx) error { + statusStorage := &status{tx} + return f(ctx, statusStorage) + }) +} diff --git a/pkg/storage/status.go b/pkg/storage/status.go index 8fd709e..0ac1265 100644 --- a/pkg/storage/status.go +++ b/pkg/storage/status.go @@ -2,6 +2,7 @@ package storage import ( "context" + "fmt" "net" "github.com/hakierspejs/long-season/pkg/models" @@ -18,20 +19,27 @@ type StatusIterator interface { ) error } +// UpdateStatusesArgs contains arguments for UpdateStatuses function. +type UpdateStatusesArgs struct { + Addresses []net.HardwareAddr + Iter StatusIterator + Counters StatusTx +} + // UpdateStatuses set online user fields, with any device's MAC equal to one // of addresses from given slice, to true and writes them to database. -func UpdateStatuses( - ctx context.Context, addresses []net.HardwareAddr, iter StatusIterator, -) error { +func UpdateStatuses(ctx context.Context, args UpdateStatusesArgs) error { - return iter.ForEachUpdate(ctx, + known, unknown := 0, 0 + err := args.Iter.ForEachUpdate(ctx, func(u models.User, devices []models.Device) (*models.User, error) { result := u result.Online = false - for _, address := range addresses { + for _, address := range args.Addresses { for _, device := range devices { if err := bcrypt.CompareHashAndPassword(device.MAC, address); err == nil { + known += 1 result.Online = true return &result, nil } @@ -40,4 +48,22 @@ func UpdateStatuses( return &result, nil }) + if err != nil { + return fmt.Errorf("failed to update statuses: %w", err) + } + + unknown = len(args.Addresses) - known + + return args.Counters.DevicesStatus(ctx, + func(ctx context.Context, s Status) error { + if err := s.SetOnlineUsers(ctx, known); err != nil { + return fmt.Errorf("failed to set online users: %w", err) + } + + if err := s.SetUnknownDevices(ctx, unknown); err != nil { + return fmt.Errorf("failed to set unknown devices: %w", err) + } + + return nil + }) } diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 7df4375..16bad88 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -34,3 +34,35 @@ type Devices interface { Update(ctx context.Context, d models.Device) error Remove(ctx context.Context, id int) error } + +// Status interface provides methods for reading and +// writing numerical information about users and devices +// spending time in hackerspace. +type Status interface { + // OnlineUsers returns number of people being + // currently online. + OnlineUsers(ctx context.Context) (int, error) + + // SetOnlineUsers ovewrites number of people being + // currently online. + SetOnlineUsers(ctx context.Context, number int) error + + // UnknownDevices returns number of unknown devices + // connected to the network. + UnknownDevices(ctx context.Context) (int, error) + + // SetUnknownDevices overwrites number of unknown devices + // connected to the network. + SetUnknownDevices(ctx context.Context, number int) error +} + +// StatusTx interface provides methods for reading and +// writing numerical information about users and devices +// spending time in hackerspace in one safe transaction. +// +// Use this interface if you want to omit data races. +type StatusTx interface { + // DevicesStatus accepts function that manipulates number of + // unknown devices and online users in single safe transaction. + DevicesStatus(context.Context, func(context.Context, Status) error) error +} diff --git a/web/static/js/home.js b/web/static/js/home.js index 7cb09cc..1bbb7b1 100644 --- a/web/static/js/home.js +++ b/web/static/js/home.js @@ -18,6 +18,17 @@ ready(() => return el("p", null, text); }; + const unknownStatus = (unknownDevicesCount) => { + switch (unknownDevicesCount) { + case 0: + return el("p", { style: "display:none;" }, ""); + case 1: + return el("p", null, UNKNOWN_STATE.FOREVER_ALONE); + default: + return el("p", null, UNKNOWN_STATE.PARTY(unknownDevicesCount)); + } + }; + const onlineTitle = (length) => el( "h3", @@ -34,13 +45,14 @@ ready(() => ), ); - const homeComp = (users) => + const homeComp = (data) => el( "div", { id: "app" }, - onlineStatus(users.length), - onlineTitle(users.length), - usersComp(users), + onlineStatus(data.users.length), + unknownStatus(data.unknownDevices), + onlineTitle(data.users.length), + usersComp(data.users), ); const HACKER_STATE = { @@ -49,7 +61,17 @@ ready(() => PARTY: (num) => "There are " + num + " people in the hackerspace.", }; - const users = valoo([]); + const UNKNOWN_STATE = { + FOREVER_ALONE: "There is one unknown device in the hackerspace.", + PARTY: (num) => + "There are " + num + " unknown devices in the hakerspace.", + }; + + const homeStorage = valoo({ + users: [], + onlineUsers: 0, + unknownDevices: 0, + }); const replace = (toReplace, replecament) => { if (toReplace !== null) { @@ -58,13 +80,13 @@ ready(() => } }; - users((data) => { + homeStorage((data) => { replace(document.getElementById("app"), homeComp(data)); }); const clearApp = () => el("div", { "id": "app" }, ""); - const downloadUsers = () => { + const fetchData = () => { const info = document.getElementById("info"); // clear info text @@ -72,14 +94,33 @@ ready(() => fetch("/api/v1/users?online=true") .then((response) => response.json()) - .then((data) => users(data)) + .then((users) => + homeStorage({ + ...homeStorage(), + users: users, + }) + ) + .catch(() => { + info.innerText = "Failed to load users data."; + replace(document.getElementById("app"), clearApp()); + }); + + fetch("/api/v1/status") + .then((response) => response.json()) + .then((data) => + homeStorage({ + ...homeStorage(), + onlineUsers: data.online, + unknownDevices: data.unknown, + }) + ) .catch(() => { info.innerText = "Failed to load users data."; replace(document.getElementById("app"), clearApp()); }); }; - downloadUsers(); - window.setInterval(downloadUsers, 1000 * 60 * 2); + fetchData(); + window.setInterval(fetchData, 1000 * 60 * 2); })(el) );