hicli/sync,web/mainscreen: add sync status indicator

Fixes #500
This commit is contained in:
Tulir Asokan 2024-11-21 00:47:48 +02:00
parent 74e97c5c8c
commit f3717505bf
12 changed files with 139 additions and 22 deletions

2
go.mod
View file

@ -24,7 +24,7 @@ require (
golang.org/x/text v0.20.0 golang.org/x/text v0.20.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0 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 mvdan.cc/xurls/v2 v2.5.0
) )

4
go.sum
View file

@ -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= 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 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= 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.20241120171618-4cd2bb62ff01 h1:8zrcggLi5IyHO9oBxjejbRyvgT0ePEwYLdFCdtohDnI=
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/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

View file

@ -33,7 +33,7 @@ import (
"go.mau.fi/gomuks/pkg/hicli" "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) writer, err := conn.Writer(ctx, websocket.MessageText)
if err != nil { if err != nil {
return err 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") log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent response to command")
} }
} }
initData, initErr := json.Marshal(gmx.Client.State()) initErr := writeCmd(ctx, conn, &hicli.JSONCommandCustom[*hicli.ClientState]{
if initErr != nil {
log.Err(initErr).Msg("Failed to marshal init message")
return
}
initErr = writeCmd(ctx, conn, &hicli.JSONCommand{
Command: "client_state", Command: "client_state",
Data: initData, Data: gmx.Client.State(),
}) })
if initErr != nil { 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 return
} }
go sendImageAuthToken() go sendImageAuthToken()

View file

@ -7,6 +7,7 @@
package hicli package hicli
import ( import (
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/event" "maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id" "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 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 { type EventsDecrypted struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
PreviewEventRowID database.EventRowID `json:"preview_event_rowid,omitempty"` PreviewEventRowID database.EventRowID `json:"preview_event_rowid,omitempty"`

View file

@ -45,6 +45,9 @@ type HiClient struct {
KeyBackupKey *backup.MegolmBackupKey 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) EventHandler func(evt any)
@ -87,6 +90,7 @@ func New(rawDB, cryptoDB *dbutil.Database, log zerolog.Logger, pickleKey []byte,
EventHandler: evtHandler, EventHandler: evtHandler,
} }
c.SyncStatus.Store(syncWaiting)
c.ClientStore = &database.ClientStateStore{Database: db} c.ClientStore = &database.ClientStateStore{Database: db}
c.Client = &mautrix.Client{ c.Client = &mautrix.Client{
UserAgent: mautrix.DefaultUserAgent, UserAgent: mautrix.DefaultUserAgent,
@ -243,8 +247,10 @@ func (h *HiClient) Sync() {
log.Info().Msg("Starting syncing") log.Info().Msg("Starting syncing")
err := h.Client.SyncWithContext(ctx) err := h.Client.SyncWithContext(ctx)
if err != nil && ctx.Err() == nil { if err != nil && ctx.Err() == nil {
h.markSyncErrored(err)
log.Err(err).Msg("Fatal error in syncer") log.Err(err).Msg("Fatal error in syncer")
} else { } else {
h.SyncStatus.Store(syncWaiting)
log.Info().Msg("Syncing stopped") log.Info().Msg("Syncing stopped")
} }
} }

View file

@ -16,12 +16,14 @@ import (
"go.mau.fi/util/exerrors" "go.mau.fi/util/exerrors"
) )
type JSONCommand struct { type JSONCommandCustom[T any] struct {
Command string `json:"command"` Command string `json:"command"`
RequestID int64 `json:"request_id"` RequestID int64 `json:"request_id"`
Data json.RawMessage `json:"data"` Data T `json:"data"`
} }
type JSONCommand = JSONCommandCustom[json.RawMessage]
type JSONEventHandler func(*JSONCommand) type JSONEventHandler func(*JSONCommand)
var outgoingEventCounter atomic.Int64 var outgoingEventCounter atomic.Int64
@ -31,6 +33,8 @@ func (jeh JSONEventHandler) HandleEvent(evt any) {
switch evt.(type) { switch evt.(type) {
case *SyncComplete: case *SyncComplete:
command = "sync_complete" command = "sync_complete"
case *SyncStatus:
command = "sync_status"
case *EventsDecrypted: case *EventsDecrypted:
command = "events_decrypted" command = "events_decrypted"
case *Typing: case *Typing:

View file

@ -39,6 +39,28 @@ type syncContext struct {
evt *SyncComplete 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 { func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
log := zerolog.Ctx(ctx) log := zerolog.Ctx(ctx)
postponedToDevices := resp.ToDevice.Events[:0] postponedToDevices := resp.ToDevice.Events[:0]

View file

@ -27,6 +27,7 @@ const (
func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error { func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
c := (*HiClient)(h) c := (*HiClient)(h)
c.lastSync = time.Now()
ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{ ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{
Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)), Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)),
LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)), 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 return err
} }
c.postProcessSyncResponse(ctx, resp, since) c.postProcessSyncResponse(ctx, resp, since)
c.syncErrors = 0
c.markSyncOK()
return nil return nil
} }
func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) { func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
(*HiClient)(h).Log.Err(err).Msg("Sync failed, retrying in 1 second") c := (*HiClient)(h)
return 1 * time.Second, nil 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 { func (h *hiSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CachedEventDispatcher } from "../util/eventdispatcher.ts" import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
import RPCClient, { SendMessageParams } from "./rpc.ts" import RPCClient, { SendMessageParams } from "./rpc.ts"
import { RoomStateStore, StateStore } from "./statestore" import { RoomStateStore, StateStore } from "./statestore"
import type { import type {
@ -25,11 +25,13 @@ import type {
RPCEvent, RPCEvent,
RoomID, RoomID,
RoomStateGUID, RoomStateGUID,
SyncStatus,
UserID, UserID,
} from "./types" } from "./types"
export default class Client { export default class Client {
readonly state = new CachedEventDispatcher<ClientState>() readonly state = new CachedEventDispatcher<ClientState>()
readonly syncStatus = new NonNullCachedEventDispatcher<SyncStatus>({ type: "waiting", error_count: 0 })
readonly store = new StateStore() readonly store = new StateStore()
#stateRequests: RoomStateGUID[] = [] #stateRequests: RoomStateGUID[] = []
#stateRequestQueued = false #stateRequestQueued = false
@ -82,6 +84,8 @@ export default class Client {
#handleEvent = (ev: RPCEvent) => { #handleEvent = (ev: RPCEvent) => {
if (ev.command === "client_state") { if (ev.command === "client_state") {
this.state.emit(ev.data) this.state.emit(ev.data)
} else if (ev.command === "sync_status") {
this.syncStatus.emit(ev.data)
} else if (ev.command === "sync_complete") { } else if (ev.command === "sync_complete") {
this.store.applySync(ev.data) this.store.applySync(ev.data)
} else if (ev.command === "events_decrypted") { } else if (ev.command === "events_decrypted") {

View file

@ -107,8 +107,20 @@ export interface ClientStateEvent extends RPCCommand<ClientState> {
command: "client_state" command: "client_state"
} }
export interface SyncStatus {
type: "ok" | "waiting" | "errored"
error?: string
error_count: number
last_sync?: number
}
export interface SyncStatusEvent extends RPCCommand<SyncStatus> {
command: "sync_status"
}
export type RPCEvent = export type RPCEvent =
ClientStateEvent | ClientStateEvent |
SyncStatusEvent |
TypingEvent | TypingEvent |
SendCompleteEvent | SendCompleteEvent |
EventsDecryptedEvent | EventsDecryptedEvent |

View file

@ -47,3 +47,22 @@ main.matrix-main {
grid-area: rh2; 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;
}
}

View file

@ -13,10 +13,12 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
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 Client from "@/api/client.ts"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore } from "@/api/statestore"
import type { RoomID } from "@/api/types" import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import ClientContext from "./ClientContext.ts" import ClientContext from "./ClientContext.ts"
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts" import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
import StylePreferences from "./StylePreferences.tsx" import StylePreferences from "./StylePreferences.tsx"
@ -102,10 +104,13 @@ class ContextFields implements MainScreenContextFields {
closeRightPanel = () => this.setRightPanel(null) closeRightPanel = () => this.setRightPanel(null)
} }
const SYNC_ERROR_HIDE_DELAY = 30 * 1000
const MainScreen = () => { const MainScreen = () => {
const [activeRoom, directSetActiveRoom] = useState<RoomStateStore | null>(null) const [activeRoom, directSetActiveRoom] = useState<RoomStateStore | null>(null)
const [rightPanel, setRightPanel] = useReducer(rpReducer, null) const [rightPanel, setRightPanel] = useReducer(rpReducer, null)
const client = use(ClientContext)! const client = use(ClientContext)!
const syncStatus = useEventAsState(client.syncStatus)
const context = useMemo( const context = useMemo(
() => new ContextFields(setRightPanel, directSetActiveRoom, client), () => new ContextFields(setRightPanel, directSetActiveRoom, client),
[client], [client],
@ -144,9 +149,24 @@ const MainScreen = () => {
if (rightPanel) { if (rightPanel) {
classNames.push("right-panel-open") classNames.push("right-panel-open")
} }
let syncLoader: JSX.Element | null = null
if (syncStatus.type === "waiting") {
syncLoader = <div className="sync-status waiting">
<SyncLoader color="var(--primary-color)"/>
Waiting for first sync...
</div>
} else if (
syncStatus.type === "errored"
&& (syncStatus.error_count > 2 || (syncStatus.last_sync ?? 0) + SYNC_ERROR_HIDE_DELAY < Date.now())
) {
syncLoader = <div className="sync-status errored" title={syncStatus.error}>
<SyncLoader color="var(--error-color)"/>
Sync is failing
</div>
}
return <MainScreenContext value={context}> return <MainScreenContext value={context}>
<ModalWrapper> <ModalWrapper>
<StylePreferences client={client} activeRoom={activeRoom} /> <StylePreferences client={client} activeRoom={activeRoom}/>
<main className={classNames.join(" ")} style={extraStyle}> <main className={classNames.join(" ")} style={extraStyle}>
<RoomList activeRoomID={activeRoom?.roomID ?? null}/> <RoomList activeRoomID={activeRoom?.roomID ?? null}/>
{resizeHandle1} {resizeHandle1}
@ -162,6 +182,7 @@ const MainScreen = () => {
{rightPanel && <RightPanel {...rightPanel}/>} {rightPanel && <RightPanel {...rightPanel}/>}
</>} </>}
</main> </main>
{syncLoader}
</ModalWrapper> </ModalWrapper>
</MainScreenContext> </MainScreenContext>
} }