From f3717505bf155ec76d539c7c78729315b77ce4f4 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Thu, 21 Nov 2024 00:47:48 +0200 Subject: [PATCH] hicli/sync,web/mainscreen: add sync status indicator Fixes #500 --- go.mod | 2 +- go.sum | 4 ++-- pkg/gomuks/websocket.go | 21 ++++++++++++--------- pkg/hicli/events.go | 16 ++++++++++++++++ pkg/hicli/hicli.go | 8 +++++++- pkg/hicli/json.go | 12 ++++++++---- pkg/hicli/sync.go | 22 ++++++++++++++++++++++ pkg/hicli/syncwrap.go | 14 ++++++++++++-- web/src/api/client.ts | 6 +++++- web/src/api/types/hievents.ts | 12 ++++++++++++ web/src/ui/MainScreen.css | 19 +++++++++++++++++++ web/src/ui/MainScreen.tsx | 25 +++++++++++++++++++++++-- 12 files changed, 139 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index ff2e90f..0f191e2 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( golang.org/x/text v0.20.0 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 - maunium.net/go/mautrix v0.22.1-0.20241118181122-363fdfa3b227 + maunium.net/go/mautrix v0.22.1-0.20241120171618-4cd2bb62ff01 mvdan.cc/xurls/v2 v2.5.0 ) diff --git a/go.sum b/go.sum index 534ffb4..738a9ef 100644 --- a/go.sum +++ b/go.sum @@ -89,7 +89,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.22.1-0.20241118181122-363fdfa3b227 h1:VsOldmYtN1NYh4yh2P5U+PemDsBB/zqD8z/Zqml76s0= -maunium.net/go/mautrix v0.22.1-0.20241118181122-363fdfa3b227/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM= +maunium.net/go/mautrix v0.22.1-0.20241120171618-4cd2bb62ff01 h1:8zrcggLi5IyHO9oBxjejbRyvgT0ePEwYLdFCdtohDnI= +maunium.net/go/mautrix v0.22.1-0.20241120171618-4cd2bb62ff01/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= diff --git a/pkg/gomuks/websocket.go b/pkg/gomuks/websocket.go index f06f7b6..6d9f528 100644 --- a/pkg/gomuks/websocket.go +++ b/pkg/gomuks/websocket.go @@ -33,7 +33,7 @@ import ( "go.mau.fi/gomuks/pkg/hicli" ) -func writeCmd(ctx context.Context, conn *websocket.Conn, cmd *hicli.JSONCommand) error { +func writeCmd[T any](ctx context.Context, conn *websocket.Conn, cmd *hicli.JSONCommandCustom[T]) error { writer, err := conn.Writer(ctx, websocket.MessageText) if err != nil { return err @@ -188,17 +188,20 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) { log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent response to command") } } - initData, initErr := json.Marshal(gmx.Client.State()) - if initErr != nil { - log.Err(initErr).Msg("Failed to marshal init message") - return - } - initErr = writeCmd(ctx, conn, &hicli.JSONCommand{ + initErr := writeCmd(ctx, conn, &hicli.JSONCommandCustom[*hicli.ClientState]{ Command: "client_state", - Data: initData, + Data: gmx.Client.State(), }) if initErr != nil { - log.Err(initErr).Msg("Failed to write init message") + log.Err(initErr).Msg("Failed to write init client state message") + return + } + initErr = writeCmd(ctx, conn, &hicli.JSONCommandCustom[*hicli.SyncStatus]{ + Command: "sync_status", + Data: gmx.Client.SyncStatus.Load(), + }) + if initErr != nil { + log.Err(initErr).Msg("Failed to write init sync status message") return } go sendImageAuthToken() diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go index e8e0a3b..af153cc 100644 --- a/pkg/hicli/events.go +++ b/pkg/hicli/events.go @@ -7,6 +7,7 @@ package hicli import ( + "go.mau.fi/util/jsontime" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -38,6 +39,21 @@ func (c *SyncComplete) IsEmpty() bool { return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.AccountData) == 0 } +type SyncStatusType string + +const ( + SyncStatusOK SyncStatusType = "ok" + SyncStatusWaiting SyncStatusType = "waiting" + SyncStatusErrored SyncStatusType = "errored" +) + +type SyncStatus struct { + Type SyncStatusType `json:"type"` + Error string `json:"error,omitempty"` + ErrorCount int `json:"error_count"` + LastSync jsontime.UnixMilli `json:"last_sync,omitempty"` +} + type EventsDecrypted struct { RoomID id.RoomID `json:"room_id"` PreviewEventRowID database.EventRowID `json:"preview_event_rowid,omitempty"` diff --git a/pkg/hicli/hicli.go b/pkg/hicli/hicli.go index b53d282..9969387 100644 --- a/pkg/hicli/hicli.go +++ b/pkg/hicli/hicli.go @@ -44,7 +44,10 @@ type HiClient struct { KeyBackupVersion id.KeyBackupVersion KeyBackupKey *backup.MegolmBackupKey - PushRules atomic.Pointer[pushrules.PushRuleset] + PushRules atomic.Pointer[pushrules.PushRuleset] + SyncStatus atomic.Pointer[SyncStatus] + syncErrors int + lastSync time.Time EventHandler func(evt any) @@ -87,6 +90,7 @@ func New(rawDB, cryptoDB *dbutil.Database, log zerolog.Logger, pickleKey []byte, EventHandler: evtHandler, } + c.SyncStatus.Store(syncWaiting) c.ClientStore = &database.ClientStateStore{Database: db} c.Client = &mautrix.Client{ UserAgent: mautrix.DefaultUserAgent, @@ -243,8 +247,10 @@ func (h *HiClient) Sync() { log.Info().Msg("Starting syncing") err := h.Client.SyncWithContext(ctx) if err != nil && ctx.Err() == nil { + h.markSyncErrored(err) log.Err(err).Msg("Fatal error in syncer") } else { + h.SyncStatus.Store(syncWaiting) log.Info().Msg("Syncing stopped") } } diff --git a/pkg/hicli/json.go b/pkg/hicli/json.go index a27fd00..2d74eae 100644 --- a/pkg/hicli/json.go +++ b/pkg/hicli/json.go @@ -16,12 +16,14 @@ import ( "go.mau.fi/util/exerrors" ) -type JSONCommand struct { - Command string `json:"command"` - RequestID int64 `json:"request_id"` - Data json.RawMessage `json:"data"` +type JSONCommandCustom[T any] struct { + Command string `json:"command"` + RequestID int64 `json:"request_id"` + Data T `json:"data"` } +type JSONCommand = JSONCommandCustom[json.RawMessage] + type JSONEventHandler func(*JSONCommand) var outgoingEventCounter atomic.Int64 @@ -31,6 +33,8 @@ func (jeh JSONEventHandler) HandleEvent(evt any) { switch evt.(type) { case *SyncComplete: command = "sync_complete" + case *SyncStatus: + command = "sync_status" case *EventsDecrypted: command = "events_decrypted" case *Typing: diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go index c16d312..44a4c91 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -39,6 +39,28 @@ type syncContext struct { evt *SyncComplete } +func (h *HiClient) markSyncErrored(err error) { + stat := &SyncStatus{ + Type: SyncStatusErrored, + Error: err.Error(), + ErrorCount: h.syncErrors, + LastSync: jsontime.UM(h.lastSync), + } + h.SyncStatus.Store(stat) + h.EventHandler(stat) +} + +var ( + syncOK = &SyncStatus{Type: SyncStatusOK} + syncWaiting = &SyncStatus{Type: SyncStatusWaiting} +) + +func (h *HiClient) markSyncOK() { + if h.SyncStatus.Swap(syncOK) != syncOK { + h.EventHandler(syncOK) + } +} + func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) error { log := zerolog.Ctx(ctx) postponedToDevices := resp.ToDevice.Events[:0] diff --git a/pkg/hicli/syncwrap.go b/pkg/hicli/syncwrap.go index 966196d..5e41f73 100644 --- a/pkg/hicli/syncwrap.go +++ b/pkg/hicli/syncwrap.go @@ -27,6 +27,7 @@ const ( func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error { c := (*HiClient)(h) + c.lastSync = time.Now() ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{ Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)), LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)), @@ -42,12 +43,21 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, return err } c.postProcessSyncResponse(ctx, resp, since) + c.syncErrors = 0 + c.markSyncOK() return nil } func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) { - (*HiClient)(h).Log.Err(err).Msg("Sync failed, retrying in 1 second") - return 1 * time.Second, nil + c := (*HiClient)(h) + c.syncErrors++ + delay := 1 * time.Second + if c.syncErrors > 5 { + delay = max(time.Duration(c.syncErrors)*time.Second, 30*time.Second) + } + c.markSyncErrored(err) + c.Log.Err(err).Dur("retry_in", delay).Msg("Sync failed") + return delay, nil } func (h *hiSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b9330f3..24f2f9c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { CachedEventDispatcher } from "../util/eventdispatcher.ts" +import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts" import RPCClient, { SendMessageParams } from "./rpc.ts" import { RoomStateStore, StateStore } from "./statestore" import type { @@ -25,11 +25,13 @@ import type { RPCEvent, RoomID, RoomStateGUID, + SyncStatus, UserID, } from "./types" export default class Client { readonly state = new CachedEventDispatcher() + readonly syncStatus = new NonNullCachedEventDispatcher({ type: "waiting", error_count: 0 }) readonly store = new StateStore() #stateRequests: RoomStateGUID[] = [] #stateRequestQueued = false @@ -82,6 +84,8 @@ export default class Client { #handleEvent = (ev: RPCEvent) => { if (ev.command === "client_state") { this.state.emit(ev.data) + } else if (ev.command === "sync_status") { + this.syncStatus.emit(ev.data) } else if (ev.command === "sync_complete") { this.store.applySync(ev.data) } else if (ev.command === "events_decrypted") { diff --git a/web/src/api/types/hievents.ts b/web/src/api/types/hievents.ts index 9abe597..69952f7 100644 --- a/web/src/api/types/hievents.ts +++ b/web/src/api/types/hievents.ts @@ -107,8 +107,20 @@ export interface ClientStateEvent extends RPCCommand { command: "client_state" } +export interface SyncStatus { + type: "ok" | "waiting" | "errored" + error?: string + error_count: number + last_sync?: number +} + +export interface SyncStatusEvent extends RPCCommand { + command: "sync_status" +} + export type RPCEvent = ClientStateEvent | + SyncStatusEvent | TypingEvent | SendCompleteEvent | EventsDecryptedEvent | diff --git a/web/src/ui/MainScreen.css b/web/src/ui/MainScreen.css index 0b8e349..769c6f0 100644 --- a/web/src/ui/MainScreen.css +++ b/web/src/ui/MainScreen.css @@ -47,3 +47,22 @@ main.matrix-main { grid-area: rh2; } } + +div.sync-status { + position: fixed; + top: 1rem; + left: 50%; + transform: translate(-50%, 0); + padding: 1.5rem; + background-color: var(--background-color); + border: 1px solid var(--border-color); + border-radius: .5rem; + display: flex; + gap: 1rem; + + &.errored { + border: 2px solid var(--error-color); + color: var(--error-color); + font-weight: bold; + } +} diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 01d811b..a704fcc 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -13,10 +13,12 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { use, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useReducer, useState } from "react" +import { JSX, use, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useReducer, useState } from "react" +import { SyncLoader } from "react-spinners" import Client from "@/api/client.ts" import { RoomStateStore } from "@/api/statestore" import type { RoomID } from "@/api/types" +import { useEventAsState } from "@/util/eventdispatcher.ts" import ClientContext from "./ClientContext.ts" import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts" import StylePreferences from "./StylePreferences.tsx" @@ -102,10 +104,13 @@ class ContextFields implements MainScreenContextFields { closeRightPanel = () => this.setRightPanel(null) } +const SYNC_ERROR_HIDE_DELAY = 30 * 1000 + const MainScreen = () => { const [activeRoom, directSetActiveRoom] = useState(null) const [rightPanel, setRightPanel] = useReducer(rpReducer, null) const client = use(ClientContext)! + const syncStatus = useEventAsState(client.syncStatus) const context = useMemo( () => new ContextFields(setRightPanel, directSetActiveRoom, client), [client], @@ -144,9 +149,24 @@ const MainScreen = () => { if (rightPanel) { classNames.push("right-panel-open") } + let syncLoader: JSX.Element | null = null + if (syncStatus.type === "waiting") { + syncLoader =
+ + Waiting for first sync... +
+ } else if ( + syncStatus.type === "errored" + && (syncStatus.error_count > 2 || (syncStatus.last_sync ?? 0) + SYNC_ERROR_HIDE_DELAY < Date.now()) + ) { + syncLoader =
+ + Sync is failing +
+ } return - +
{resizeHandle1} @@ -162,6 +182,7 @@ const MainScreen = () => { {rightPanel && } }
+ {syncLoader}
}