Commit b47e6601 by Mac Stephens

Improve mobile reliability, map controls, and WebSocket recovery

parent ab0baace
...@@ -87,7 +87,7 @@ object FMain: TFMain ...@@ -87,7 +87,7 @@ object FMain: TFMain
Top = 18 Top = 18
Width = 141 Width = 141
Height = 25 Height = 25
Caption = 'Disconnect Selected Client' Caption = 'Refresh Client'
TabOrder = 0 TabOrder = 0
OnClick = btnDisconnectClientClick OnClick = btnDisconnectClientClick
end end
......
...@@ -7,11 +7,14 @@ uses ...@@ -7,11 +7,14 @@ uses
System.SysUtils, System.SysUtils,
System.JSON, System.JSON,
System.Generics.Collections, System.Generics.Collections,
Vcl.ExtCtrls,
VCL.TMSFNCWebSocketServer, VCL.TMSFNCWebSocketServer,
VCL.TMSFNCWebSocketCommon; VCL.TMSFNCWebSocketCommon;
const const
WEBSOCKET_PORT = 8091; WEBSOCKET_PORT = 8091;
HEARTBEAT_TIMEOUT_MS = 75000;
HEARTBEAT_SWEEP_INTERVAL_MS = 10000;
type type
TConnectedClientSnapshot = record TConnectedClientSnapshot = record
...@@ -25,11 +28,17 @@ type ...@@ -25,11 +28,17 @@ type
FConnectionId: string; FConnectionId: string;
FUserId: string; FUserId: string;
FConnectedAt: TDateTime; FConnectedAt: TDateTime;
FLastSeenAt: TDateTime;
FHeartbeatSeen: Boolean;
FClosing: Boolean;
FConnection: TTMSFNCWebSocketServerConnection; FConnection: TTMSFNCWebSocketServerConnection;
public public
property ConnectionId: string read FConnectionId write FConnectionId; property ConnectionId: string read FConnectionId write FConnectionId;
property UserId: string read FUserId write FUserId; property UserId: string read FUserId write FUserId;
property ConnectedAt: TDateTime read FConnectedAt write FConnectedAt; property ConnectedAt: TDateTime read FConnectedAt write FConnectedAt;
property LastSeenAt: TDateTime read FLastSeenAt write FLastSeenAt;
property HeartbeatSeen: Boolean read FHeartbeatSeen write FHeartbeatSeen;
property Closing: Boolean read FClosing write FClosing;
property Connection: TTMSFNCWebSocketServerConnection read FConnection write FConnection; property Connection: TTMSFNCWebSocketServerConnection read FConnection write FConnection;
end; end;
...@@ -40,8 +49,14 @@ type ...@@ -40,8 +49,14 @@ type
FServer: TTMSFNCWebSocketServer; FServer: TTMSFNCWebSocketServer;
FClients: TObjectList<TConnectedClient>; FClients: TObjectList<TConnectedClient>;
FClientsLock: TObject; FClientsLock: TObject;
FSendLock: TObject;
FHeartbeatTimer: TTimer;
FOnClientsChanged: TClientsChangedEvent; FOnClientsChanged: TClientsChangedEvent;
function TrySendTextToClient(const AConnectionId, AMessage: string;
ALogFailure: Boolean = True): Boolean;
function TryCloseClient(const AConnectionId: string): Boolean;
procedure HeartbeatTimer(Sender: TObject);
procedure NotifyClientsChanged; procedure NotifyClientsChanged;
procedure HandshakeResponseSent(Sender: TObject; AConnection: TTMSFNCWebSocketServerConnection); procedure HandshakeResponseSent(Sender: TObject; AConnection: TTMSFNCWebSocketServerConnection);
procedure MessageReceived(Sender: TObject; AConnection: TTMSFNCWebSocketConnection; const AMessage: string); procedure MessageReceived(Sender: TObject; AConnection: TTMSFNCWebSocketConnection; const AMessage: string);
...@@ -63,6 +78,7 @@ type ...@@ -63,6 +78,7 @@ type
implementation implementation
uses uses
System.DateUtils,
Common.Logging; Common.Logging;
constructor TWebSocketManager.Create; constructor TWebSocketManager.Create;
...@@ -70,6 +86,7 @@ begin ...@@ -70,6 +86,7 @@ begin
inherited Create; inherited Create;
FClientsLock := TObject.Create; FClientsLock := TObject.Create;
FSendLock := TObject.Create;
FClients := TObjectList<TConnectedClient>.Create(True); FClients := TObjectList<TConnectedClient>.Create(True);
FServer := TTMSFNCWebSocketServer.Create; FServer := TTMSFNCWebSocketServer.Create;
...@@ -78,13 +95,21 @@ begin ...@@ -78,13 +95,21 @@ begin
FServer.OnHandshakeResponseSent := HandshakeResponseSent; FServer.OnHandshakeResponseSent := HandshakeResponseSent;
FServer.OnMessageReceived := MessageReceived; FServer.OnMessageReceived := MessageReceived;
FServer.OnDisconnect := ClientDisconnected; FServer.OnDisconnect := ClientDisconnected;
FHeartbeatTimer := TTimer.Create(nil);
FHeartbeatTimer.Enabled := False;
FHeartbeatTimer.Interval := HEARTBEAT_SWEEP_INTERVAL_MS;
FHeartbeatTimer.OnTimer := HeartbeatTimer;
end; end;
destructor TWebSocketManager.Destroy; destructor TWebSocketManager.Destroy;
begin begin
FHeartbeatTimer.Enabled := False;
Stop; Stop;
FHeartbeatTimer.Free;
FServer.Free; FServer.Free;
FClients.Free; FClients.Free;
FSendLock.Free;
FClientsLock.Free; FClientsLock.Free;
inherited; inherited;
...@@ -93,94 +118,173 @@ end; ...@@ -93,94 +118,173 @@ end;
procedure TWebSocketManager.Start; procedure TWebSocketManager.Start;
begin begin
FServer.Active := True; FServer.Active := True;
FHeartbeatTimer.Enabled := True;
end; end;
procedure TWebSocketManager.Stop; procedure TWebSocketManager.Stop;
begin begin
if Assigned(FHeartbeatTimer) then
FHeartbeatTimer.Enabled := False;
FServer.Active := False; FServer.Active := False;
end; end;
procedure TWebSocketManager.Broadcast(AMessage: string); function TWebSocketManager.TrySendTextToClient(const AConnectionId,
AMessage: string; ALogFailure: Boolean): Boolean;
var var
connections: TArray<TTMSFNCWebSocketServerConnection>; client: TConnectedClient;
i: Integer; connection: TTMSFNCWebSocketServerConnection;
begin begin
TMonitor.Enter(FClientsLock); Result := False;
connection := nil;
// TMS owns and frees the connection immediately after its disconnect
// callback returns. Holding FSendLock makes that callback wait until the
// send has completed, while FClientsLock protects the registry lookup.
TMonitor.Enter(FSendLock);
try try
SetLength(connections, FClients.Count); TMonitor.Enter(FClientsLock);
try
for client in FClients do
begin
if SameText(client.ConnectionId, AConnectionId) then
begin
if not client.Closing then
connection := client.Connection;
Break;
end;
end;
finally
TMonitor.Exit(FClientsLock);
end;
for i := 0 to FClients.Count - 1 do if not Assigned(connection) then
connections[i] := FClients[i].Connection; Exit;
finally
TMonitor.Exit(FClientsLock);
end;
for i := 0 to Length(connections) - 1 do
begin
try try
connections[i].Send(AMessage); connection.Send(AMessage);
Result := True;
except except
on E: Exception do on E: Exception do
Logger.Log(2, 'WebSocket broadcast failed: ' + E.Message); begin
if ALogFailure then
Logger.Log(2, 'WebSocket send failed: ' + E.Message);
end;
end; end;
finally
TMonitor.Exit(FSendLock);
end; end;
end; end;
procedure TWebSocketManager.DisconnectClient(AConnectionId: string); function TWebSocketManager.TryCloseClient(
const AConnectionId: string): Boolean;
var var
client: TConnectedClient; client: TConnectedClient;
connection: TTMSFNCWebSocketServerConnection; connection: TTMSFNCWebSocketServerConnection;
begin begin
Result := False;
connection := nil; connection := nil;
TMonitor.Enter(FClientsLock); TMonitor.Enter(FSendLock);
try try
for client in FClients do TMonitor.Enter(FClientsLock);
begin try
if SameText(client.ConnectionId, AConnectionId) then for client in FClients do
begin begin
connection := client.Connection; if SameText(client.ConnectionId, AConnectionId) then
Break; begin
client.Closing := True;
connection := client.Connection;
Break;
end;
end; end;
finally
TMonitor.Exit(FClientsLock);
end;
if not Assigned(connection) then
Exit;
try
connection.SendClose;
Result := True;
except
on E: Exception do
Logger.Log(2, 'WebSocket close failed: ' + E.Message);
end; end;
finally finally
TMonitor.Exit(FClientsLock); TMonitor.Exit(FSendLock);
end; end;
if Assigned(connection) then
connection.SendClose;
end; end;
procedure TWebSocketManager.SendMessageToClient(AConnectionId, AText: string); procedure TWebSocketManager.HeartbeatTimer(Sender: TObject);
var var
staleConnectionIds: TList<string>;
client: TConnectedClient; client: TConnectedClient;
connection: TTMSFNCWebSocketServerConnection; connectionId: string;
json: TJSONObject; checkTime: TDateTime;
begin begin
connection := nil; staleConnectionIds := TList<string>.Create;
TMonitor.Enter(FClientsLock);
try try
for client in FClients do checkTime := Now;
begin
if SameText(client.ConnectionId, AConnectionId) then TMonitor.Enter(FClientsLock);
try
for client in FClients do
begin begin
connection := client.Connection; if client.HeartbeatSeen and (not client.Closing) and
Break; (MilliSecondsBetween(checkTime, client.LastSeenAt) >= HEARTBEAT_TIMEOUT_MS) then
begin
client.Closing := True;
staleConnectionIds.Add(client.ConnectionId);
end;
end; end;
finally
TMonitor.Exit(FClientsLock);
end;
for connectionId in staleConnectionIds do
begin
Logger.Log(2, 'WebSocket client heartbeat timeout: ' + connectionId);
TryCloseClient(connectionId);
end; end;
finally finally
staleConnectionIds.Free;
end;
end;
procedure TWebSocketManager.Broadcast(AMessage: string);
var
connectionIds: TArray<string>;
i: Integer;
begin
TMonitor.Enter(FClientsLock);
try
SetLength(connectionIds, FClients.Count);
for i := 0 to FClients.Count - 1 do
connectionIds[i] := FClients[i].ConnectionId;
finally
TMonitor.Exit(FClientsLock); TMonitor.Exit(FClientsLock);
end; end;
if not Assigned(connection) then for i := 0 to Length(connectionIds) - 1 do
Exit; TrySendTextToClient(connectionIds[i], AMessage);
end;
procedure TWebSocketManager.DisconnectClient(AConnectionId: string);
begin
TryCloseClient(AConnectionId);
end;
procedure TWebSocketManager.SendMessageToClient(AConnectionId, AText: string);
var
json: TJSONObject;
begin
json := TJSONObject.Create; json := TJSONObject.Create;
try try
json.AddPair('message', 'test_message'); json.AddPair('message', 'test_message');
json.AddPair('text', AText); json.AddPair('text', AText);
connection.Send(json.ToJSON); TrySendTextToClient(AConnectionId, json.ToJSON);
finally finally
json.Free; json.Free;
end; end;
...@@ -202,6 +306,9 @@ begin ...@@ -202,6 +306,9 @@ begin
client := TConnectedClient.Create; client := TConnectedClient.Create;
client.ConnectionId := GUIDToString(guid); client.ConnectionId := GUIDToString(guid);
client.ConnectedAt := Now; client.ConnectedAt := Now;
client.LastSeenAt := client.ConnectedAt;
client.HeartbeatSeen := False;
client.Closing := False;
client.Connection := AConnection; client.Connection := AConnection;
TMonitor.Enter(FClientsLock); TMonitor.Enter(FClientsLock);
...@@ -225,6 +332,7 @@ var ...@@ -225,6 +332,7 @@ var
userId: string; userId: string;
connectionId: string; connectionId: string;
client: TConnectedClient; client: TConnectedClient;
response: TJSONObject;
begin begin
json := TJSONObject.ParseJSONValue(AMessage); json := TJSONObject.ParseJSONValue(AMessage);
try try
...@@ -234,10 +342,12 @@ begin ...@@ -234,10 +342,12 @@ begin
if not json.TryGetValue<string>('message', messageType) then if not json.TryGetValue<string>('message', messageType) then
Exit; Exit;
if messageType <> 'identify' then if (not SameText(messageType, 'identify')) and
(not SameText(messageType, 'heartbeat')) then
Exit; Exit;
if not json.TryGetValue<string>('userId', userId) then if SameText(messageType, 'identify') and
(not json.TryGetValue<string>('userId', userId)) then
Exit; Exit;
client := TConnectedClient(TTMSFNCWebSocketServerConnection(AConnection).UserData); client := TConnectedClient(TTMSFNCWebSocketServerConnection(AConnection).UserData);
...@@ -250,14 +360,32 @@ begin ...@@ -250,14 +360,32 @@ begin
if FClients.IndexOf(client) < 0 then if FClients.IndexOf(client) < 0 then
Exit; Exit;
client.UserId := userId; client.LastSeenAt := Now;
connectionId := client.ConnectionId; connectionId := client.ConnectionId;
if SameText(messageType, 'identify') then
client.UserId := userId
else
client.HeartbeatSeen := True;
finally finally
TMonitor.Exit(FClientsLock); TMonitor.Exit(FClientsLock);
end; end;
Logger.Log(1, 'WebSocket client identified: ' + connectionId + ' - ' + userId); if SameText(messageType, 'identify') then
NotifyClientsChanged; begin
Logger.Log(1, 'WebSocket client identified: ' + connectionId + ' - ' + userId);
NotifyClientsChanged;
end
else
begin
response := TJSONObject.Create;
try
response.AddPair('message', 'heartbeat_ack');
TrySendTextToClient(connectionId, response.ToJSON, False);
finally
response.Free;
end;
end;
finally finally
json.Free; json.Free;
end; end;
...@@ -271,20 +399,32 @@ var ...@@ -271,20 +399,32 @@ var
userId: string; userId: string;
begin begin
serverConnection := TTMSFNCWebSocketServerConnection(AConnection); serverConnection := TTMSFNCWebSocketServerConnection(AConnection);
client := TConnectedClient(serverConnection.UserData); connectionId := '';
userId := '';
if not Assigned(client) then // TMS frees AConnection immediately after this callback returns. Matching
Exit; // the safe-send lock here keeps every active send inside that lifetime.
TMonitor.Enter(FSendLock);
try
client := TConnectedClient(serverConnection.UserData);
if not Assigned(client) then
Exit;
connectionId := client.ConnectionId; TMonitor.Enter(FClientsLock);
userId := client.UserId; try
serverConnection.UserData := nil; if FClients.IndexOf(client) < 0 then
Exit;
TMonitor.Enter(FClientsLock); connectionId := client.ConnectionId;
try userId := client.UserId;
FClients.Remove(client); serverConnection.UserData := nil;
FClients.Remove(client);
finally
TMonitor.Exit(FClientsLock);
end;
finally finally
TMonitor.Exit(FClientsLock); TMonitor.Exit(FSendLock);
end; end;
Logger.Log(1, 'WebSocket client disconnected: ' + connectionId + ' - ' + userId); Logger.Log(1, 'WebSocket client disconnected: ' + connectionId + ' - ' + userId);
...@@ -310,4 +450,4 @@ begin ...@@ -310,4 +450,4 @@ begin
end; end;
end; end;
end. end.
\ No newline at end of file
[Settings] [Settings]
LogFileNum=153 LogFileNum=155
webClientVersion=0.9.4.1 webClientVersion=0.9.4.1
[Database] [Database]
......
...@@ -3,13 +3,24 @@ ...@@ -3,13 +3,24 @@
interface interface
uses uses
System.SysUtils, System.Classes, WEBLib.WebSocketClient, Web, WEBLib.Controls, WEBLib.Modules, System.SysUtils, System.Classes, WEBLib.WebSocketClient, Web, WEBLib.Controls,
Auth.Service, JS; WEBLib.Modules, Auth.Service, JS;
type type
// Handler signature: receives the fully parsed JSON object for one push message. // Handler signature: receives the fully parsed JSON object for one push message.
TWsDataHandler = procedure(aData: TJSObject) of object; TWsDataHandler = procedure(aData: TJSObject) of object;
TWsConnectionState = (
wcsStopped,
wcsConnecting,
wcsConnected,
wcsReconnecting,
wcsOffline
);
TWsConnectionStateHandler = procedure(AState: TWsConnectionState) of object;
TWsNotifyHandler = procedure of object;
TdmWebsocket = class(TWebDataModule) TdmWebsocket = class(TWebDataModule)
procedure WebDataModuleCreate(Sender: TObject); procedure WebDataModuleCreate(Sender: TObject);
procedure WebDataModuleDestroy(Sender: TObject); procedure WebDataModuleDestroy(Sender: TObject);
...@@ -25,24 +36,83 @@ type ...@@ -25,24 +36,83 @@ type
AData: TBytes); AData: TBytes);
procedure DispatchMessage(const AMessage: string); procedure DispatchMessage(const AMessage: string);
procedure AttemptConnect;
procedure ScheduleReconnect(AImmediate: Boolean);
procedure RequestSocketRestart(AImmediate: Boolean);
procedure ScheduleHeartbeat(ADelayMs: Integer);
procedure SendHeartbeat;
procedure SendHeartbeatAck;
procedure NoteSocketActivity;
procedure HandleAuthenticationExpired;
procedure SetConnectionState(AState: TWsConnectionState);
procedure CancelReconnectTimer;
procedure CancelConnectTimeout;
procedure CancelHeartbeatTimer;
procedure CancelHeartbeatTimeout;
procedure CancelAllTimers;
procedure RegisterLifecycleListeners;
procedure UnregisterLifecycleListeners;
procedure AttachSocketEvents;
procedure DetachSocketEvents(ASocket: TWebSocketClient);
procedure ReplaceSocket(ACreateReplacement: Boolean);
procedure HandlePause(Event: TJSEvent);
procedure HandleResume(Event: TJSEvent);
procedure HandleVisibilityChange(Event: TJSEvent);
procedure HandleOnline(Event: TJSEvent);
procedure HandleOffline(Event: TJSEvent);
procedure EnterPausedState;
procedure ResumeFromPausedState;
function ConfigureSocket: Boolean;
function AuthenticationIsValid: Boolean;
function BrowserIsOnline: Boolean;
function DocumentIsHidden: Boolean;
function NextReconnectDelayMs: Integer;
function AddJitter(ABaseMs: Integer): Integer;
function UrlEncode(const AValue: string): string;
FBaseWsUrl: string;
FState: TWsConnectionState;
FStarted: Boolean;
FPaused: Boolean;
FConnecting: Boolean;
FTransportMayBeActive: Boolean;
FAttemptedConnection: Boolean;
FHasConnected: Boolean;
FRecoveryPending: Boolean;
FHeartbeatOutstanding: Boolean;
FAuthenticationNotified: Boolean;
FDestroyingManager: Boolean;
FLifecycleListenersRegistered: Boolean;
FReconnectAttempt: Integer;
FReconnectTimerId: NativeInt;
FConnectTimeoutId: NativeInt;
FHeartbeatTimerId: NativeInt;
FHeartbeatTimeoutId: NativeInt;
FOnBadgeCounts: TWsDataHandler; FOnBadgeCounts: TWsDataHandler;
FOnUnitMap: TWsDataHandler; FOnUnitMap: TWsDataHandler;
FOnComplaintMap: TWsDataHandler; FOnComplaintMap: TWsDataHandler;
FOnUnitList: TWsDataHandler; FOnUnitList: TWsDataHandler;
FOnComplaintList: TWsDataHandler; FOnComplaintList: TWsDataHandler;
FOnStateChanged: TWsConnectionStateHandler;
FOnRecoveryRequired: TWsNotifyHandler;
FOnAuthenticationExpired: TWsNotifyHandler;
public public
EMiMobileWebSocketClient: TWebSocketClient; EMiMobileWebSocketClient: TWebSocketClient;
procedure Connect(const AWsUrl: string); procedure Connect(const AWsUrl: string);
procedure Stop;
// Assign these before calling Connect so that pushes are routed immediately. // Assign these before calling Connect so pushes and state changes are routed immediately.
property OnBadgeCounts: TWsDataHandler read FOnBadgeCounts write FOnBadgeCounts; property OnBadgeCounts: TWsDataHandler read FOnBadgeCounts write FOnBadgeCounts;
property OnUnitMap: TWsDataHandler read FOnUnitMap write FOnUnitMap; property OnUnitMap: TWsDataHandler read FOnUnitMap write FOnUnitMap;
property OnComplaintMap: TWsDataHandler read FOnComplaintMap write FOnComplaintMap; property OnComplaintMap: TWsDataHandler read FOnComplaintMap write FOnComplaintMap;
property OnUnitList: TWsDataHandler read FOnUnitList write FOnUnitList; property OnUnitList: TWsDataHandler read FOnUnitList write FOnUnitList;
property OnComplaintList: TWsDataHandler read FOnComplaintList write FOnComplaintList; property OnComplaintList: TWsDataHandler read FOnComplaintList write FOnComplaintList;
property OnStateChanged: TWsConnectionStateHandler read FOnStateChanged write FOnStateChanged;
property OnRecoveryRequired: TWsNotifyHandler read FOnRecoveryRequired write FOnRecoveryRequired;
property OnAuthenticationExpired: TWsNotifyHandler read FOnAuthenticationExpired write FOnAuthenticationExpired;
property State: TWsConnectionState read FState;
end; end;
var var
...@@ -54,23 +124,105 @@ implementation ...@@ -54,23 +124,105 @@ implementation
{$R *.dfm} {$R *.dfm}
procedure TdmWebsocket.Connect(const AWsUrl: string); const
CONNECT_TIMEOUT_MS = 15000;
HEARTBEAT_INTERVAL_MS = 20000;
HEARTBEAT_TIMEOUT_MS = 10000;
function TdmWebsocket.AddJitter(ABaseMs: Integer): Integer;
begin
Result := ABaseMs;
asm
Result = ABaseMs + Math.round(((Math.random() * 0.2) - 0.1) * ABaseMs);
end;
if Result < 250 then
Result := 250;
end;
function TdmWebsocket.AuthenticationIsValid: Boolean;
begin
Result := False;
try
Result := AuthService.Authenticated and (not AuthService.TokenExpired);
except
Result := False;
end;
end;
function TdmWebsocket.BrowserIsOnline: Boolean;
begin
Result := True;
asm
if ((typeof navigator !== 'undefined') && ('onLine' in navigator)) {
Result = navigator.onLine !== false;
}
end;
end;
procedure TdmWebsocket.CancelAllTimers;
begin
CancelReconnectTimer;
CancelConnectTimeout;
CancelHeartbeatTimer;
CancelHeartbeatTimeout;
end;
procedure TdmWebsocket.CancelConnectTimeout;
begin
if FConnectTimeoutId = 0 then
Exit;
window.clearTimeout(FConnectTimeoutId);
FConnectTimeoutId := 0;
end;
procedure TdmWebsocket.CancelHeartbeatTimeout;
begin
if FHeartbeatTimeoutId = 0 then
Exit;
window.clearTimeout(FHeartbeatTimeoutId);
FHeartbeatTimeoutId := 0;
end;
procedure TdmWebsocket.CancelHeartbeatTimer;
begin
if FHeartbeatTimerId = 0 then
Exit;
window.clearTimeout(FHeartbeatTimerId);
FHeartbeatTimerId := 0;
end;
procedure TdmWebsocket.CancelReconnectTimer;
begin
if FReconnectTimerId = 0 then
Exit;
window.clearTimeout(FReconnectTimerId);
FReconnectTimerId := 0;
end;
function TdmWebsocket.ConfigureSocket: Boolean;
var var
Rest, HostPort, Scheme, Path, Token: string; Rest, HostPort, Scheme, Path, Token: string;
ColonSlashSlash, SlashPos, ColonPos: Integer; ColonSlashSlash, SlashPos, ColonPos: Integer;
begin begin
if AWsUrl = '' then Result := False;
Exit;
// Parse ws://host:port/path or wss://host:port/path ColonSlashSlash := Pos('://', FBaseWsUrl);
ColonSlashSlash := Pos('://', AWsUrl);
if ColonSlashSlash = 0 then if ColonSlashSlash = 0 then
Exit; Exit;
Scheme := LowerCase(Copy(AWsUrl, 1, ColonSlashSlash - 1)); Scheme := LowerCase(Copy(FBaseWsUrl, 1, ColonSlashSlash - 1));
Rest := Copy(AWsUrl, ColonSlashSlash + 3, MaxInt); if (Scheme <> 'ws') and (Scheme <> 'wss') then
Exit;
Rest := Copy(FBaseWsUrl, ColonSlashSlash + 3, MaxInt);
SlashPos := Pos('/', Rest); SlashPos := Pos('/', Rest);
if SlashPos > 0 then if SlashPos > 0 then
begin begin
HostPort := Copy(Rest, 1, SlashPos - 1); HostPort := Copy(Rest, 1, SlashPos - 1);
...@@ -82,14 +234,18 @@ begin ...@@ -82,14 +234,18 @@ begin
Path := '/'; Path := '/';
end; end;
// Append JWT token as query param — browsers can't set Authorization headers on WebSocket. if HostPort = '' then
Exit;
// Browsers cannot add an Authorization header during a WebSocket handshake.
// Build this path for every attempt so a stale token is never reused.
Token := AuthService.GetToken; Token := AuthService.GetToken;
if Token <> '' then if Token <> '' then
begin begin
if Pos('?', Path) > 0 then if Pos('?', Path) > 0 then
Path := Path + '&token=' + Token Path := Path + '&token=' + UrlEncode(Token)
else else
Path := Path + '?token=' + Token; Path := Path + '?token=' + UrlEncode(Token);
end; end;
EMiMobileWebSocketClient.PathName := Path; EMiMobileWebSocketClient.PathName := Path;
...@@ -99,9 +255,11 @@ begin ...@@ -99,9 +255,11 @@ begin
begin begin
EMiMobileWebSocketClient.HostName := Copy(HostPort, 1, ColonPos - 1); EMiMobileWebSocketClient.HostName := Copy(HostPort, 1, ColonPos - 1);
if Scheme = 'wss' then if Scheme = 'wss' then
EMiMobileWebSocketClient.Port := StrToIntDef(Copy(HostPort, ColonPos + 1, MaxInt), 443) EMiMobileWebSocketClient.Port := StrToIntDef(
Copy(HostPort, ColonPos + 1, MaxInt), 443)
else else
EMiMobileWebSocketClient.Port := StrToIntDef(Copy(HostPort, ColonPos + 1, MaxInt), 80); EMiMobileWebSocketClient.Port := StrToIntDef(
Copy(HostPort, ColonPos + 1, MaxInt), 80);
end end
else else
begin begin
...@@ -112,12 +270,209 @@ begin ...@@ -112,12 +270,209 @@ begin
EMiMobileWebSocketClient.Port := 80; EMiMobileWebSocketClient.Port := 80;
end; end;
console.log('WS: connecting to ' + AWsUrl); EMiMobileWebSocketClient.UseSSL := Scheme = 'wss';
Result := True;
end;
procedure TdmWebsocket.Connect(const AWsUrl: string);
begin
if Trim(AWsUrl) = '' then
begin
Stop;
console.log('WS: no WebSocket URL configured');
Exit;
end;
if FStarted and SameText(FBaseWsUrl, AWsUrl) then
begin
if FPaused or (FState = wcsConnected) or (FState = wcsConnecting) or
(FReconnectTimerId <> 0) then
Exit;
ScheduleReconnect(True);
Exit;
end;
if FStarted then
begin
FBaseWsUrl := AWsUrl;
FRecoveryPending := True;
FReconnectAttempt := 0;
RequestSocketRestart(True);
Exit;
end;
FBaseWsUrl := AWsUrl;
FStarted := True;
FPaused := DocumentIsHidden;
FConnecting := False;
FAttemptedConnection := False;
FHasConnected := False;
FRecoveryPending := False;
FHeartbeatOutstanding := False;
FAuthenticationNotified := False;
FReconnectAttempt := 0;
if FPaused then
begin
SetConnectionState(wcsStopped);
Exit;
end;
if not AuthenticationIsValid then
begin
HandleAuthenticationExpired;
Exit;
end;
if not BrowserIsOnline then
begin
SetConnectionState(wcsOffline);
Exit;
end;
AttemptConnect;
end;
procedure TdmWebsocket.AttemptConnect;
begin
if (not FStarted) or FPaused or FDestroyingManager then
Exit;
if FConnecting or (FState = wcsConnected) then
Exit;
if not AuthenticationIsValid then
begin
HandleAuthenticationExpired;
Exit;
end;
if not BrowserIsOnline then
begin
SetConnectionState(wcsOffline);
Exit;
end;
if FTransportMayBeActive then
begin
RequestSocketRestart(True);
Exit;
end;
if not Assigned(EMiMobileWebSocketClient) then
begin
EMiMobileWebSocketClient := TWebSocketClient.Create(Self);
AttachSocketEvents;
end;
if not ConfigureSocket then
begin
console.log('WS: invalid WebSocket URL');
FStarted := False;
SetConnectionState(wcsStopped);
Exit;
end;
if FAttemptedConnection then
SetConnectionState(wcsReconnecting)
else
SetConnectionState(wcsConnecting);
FAttemptedConnection := True;
FConnecting := True;
FTransportMayBeActive := True;
CancelConnectTimeout;
FConnectTimeoutId := window.setTimeout(
procedure
begin
FConnectTimeoutId := 0;
if (not FStarted) or FPaused or (not FConnecting) then
Exit;
console.log('WS: connection attempt timed out');
FConnecting := False;
FRecoveryPending := True;
RequestSocketRestart(False);
end,
CONNECT_TIMEOUT_MS);
try
console.log('WS: connection attempt');
EMiMobileWebSocketClient.Connect;
except
on E: Exception do
begin
console.log('WS: connection attempt failed: ' + E.Message);
FConnecting := False;
FRecoveryPending := True;
RequestSocketRestart(False);
end;
end;
end;
procedure TdmWebsocket.AttachSocketEvents;
begin
if not Assigned(EMiMobileWebSocketClient) then
Exit;
EMiMobileWebSocketClient.OnConnect := EMiMobileWebSocketClientConnect;
EMiMobileWebSocketClient.OnDisconnect := EMiMobileWebSocketClientDisconnect;
EMiMobileWebSocketClient.OnDataReceived := EMiMobileWebSocketClientDataReceived;
EMiMobileWebSocketClient.OnMessageReceived := EMiMobileWebSocketClientMessageReceived;
EMiMobileWebSocketClient.OnBinaryDataReceived := EMiMobileWebSocketClientBinaryDataReceived;
end;
EMiMobileWebSocketClient.UseSSL := (Scheme = 'wss'); procedure TdmWebsocket.DetachSocketEvents(ASocket: TWebSocketClient);
EMiMobileWebSocketClient.Active := True; begin
if not Assigned(ASocket) then
Exit;
console.log('WS: Active set to true'); ASocket.OnConnect := nil;
ASocket.OnDisconnect := nil;
ASocket.OnDataReceived := nil;
ASocket.OnMessageReceived := nil;
ASocket.OnBinaryDataReceived := nil;
end;
procedure TdmWebsocket.ReplaceSocket(ACreateReplacement: Boolean);
var
oldSocket: TWebSocketClient;
begin
oldSocket := EMiMobileWebSocketClient;
EMiMobileWebSocketClient := nil;
if Assigned(oldSocket) then
begin
DetachSocketEvents(oldSocket);
try
oldSocket.Disconnect;
except
on E: Exception do
console.log('WS: disconnect failed: ' + E.Message);
end;
oldSocket.Free;
end;
FTransportMayBeActive := False;
if ACreateReplacement and (not FDestroyingManager) then
begin
EMiMobileWebSocketClient := TWebSocketClient.Create(Self);
AttachSocketEvents;
end;
end;
function TdmWebsocket.DocumentIsHidden: Boolean;
begin
Result := False;
asm
if (typeof document !== 'undefined') {
Result = !!document.hidden;
}
end;
end; end;
procedure TdmWebsocket.DispatchMessage(const AMessage: string); procedure TdmWebsocket.DispatchMessage(const AMessage: string);
...@@ -148,6 +503,15 @@ begin ...@@ -148,6 +503,15 @@ begin
messageType := string(obj['message']); messageType := string(obj['message']);
if SameText(messageType, 'heartbeat_ack') then
Exit;
if SameText(messageType, 'heartbeat') then
begin
SendHeartbeatAck;
Exit;
end;
if SameText(messageType, 'test_message') then if SameText(messageType, 'test_message') then
begin begin
messageText := string(obj['text']); messageText := string(obj['text']);
...@@ -187,51 +551,552 @@ end; ...@@ -187,51 +551,552 @@ end;
procedure TdmWebsocket.EMiMobileWebSocketClientBinaryDataReceived( procedure TdmWebsocket.EMiMobileWebSocketClientBinaryDataReceived(
Sender: TObject; AData: TBytes); Sender: TObject; AData: TBytes);
begin begin
if Sender <> EMiMobileWebSocketClient then
Exit;
if not AuthenticationIsValid then
begin
HandleAuthenticationExpired;
Exit;
end;
NoteSocketActivity;
console.log('WS: binary data received (ignored)'); console.log('WS: binary data received (ignored)');
end; end;
procedure TdmWebsocket.EMiMobileWebSocketClientConnect(Sender: TObject); procedure TdmWebsocket.EMiMobileWebSocketClientConnect(Sender: TObject);
var var
msg: TJSObject; msg: TJSObject;
payload: TJSObject;
userId: string;
recoveryRequired: Boolean;
begin begin
if Sender <> EMiMobileWebSocketClient then
Exit;
if (not FStarted) or FDestroyingManager then
begin
ReplaceSocket(False);
Exit;
end;
if FPaused then
begin
SetConnectionState(wcsStopped);
ReplaceSocket(True);
Exit;
end;
if not BrowserIsOnline then
begin
SetConnectionState(wcsOffline);
ReplaceSocket(True);
Exit;
end;
if not AuthenticationIsValid then
begin
HandleAuthenticationExpired;
Exit;
end;
try
payload := AuthService.TokenPayload;
if not Assigned(payload) then
begin
HandleAuthenticationExpired;
Exit;
end;
userId := JS.toString(payload.Properties['user_name']);
if (Trim(userId) = '') or SameText(userId, 'undefined') or
SameText(userId, 'null') then
begin
HandleAuthenticationExpired;
Exit;
end;
msg := TJSObject.new;
msg['message'] := 'identify';
msg['userId'] := userId;
EMiMobileWebSocketClient.Send(TJSJSON.stringify(msg));
except
on E: Exception do
begin
console.log('WS: identify failed: ' + E.Message);
FConnecting := False;
FRecoveryPending := True;
RequestSocketRestart(False);
Exit;
end;
end;
console.log('WS: connected'); console.log('WS: connected');
CancelConnectTimeout;
CancelReconnectTimer;
FConnecting := False;
FTransportMayBeActive := True;
FHeartbeatOutstanding := False;
FReconnectAttempt := 0;
FAuthenticationNotified := False;
msg := TJSObject.new; recoveryRequired := FRecoveryPending or FHasConnected;
msg['message'] := 'identify'; FRecoveryPending := False;
msg['userId'] := JS.toString(AuthService.TokenPayload.Properties['user_name']); FHasConnected := True;
EMiMobileWebSocketClient.Send(TJSJSON.stringify(msg)); SetConnectionState(wcsConnected);
ScheduleHeartbeat(1000);
if recoveryRequired and Assigned(FOnRecoveryRequired) then
FOnRecoveryRequired;
end; end;
procedure TdmWebsocket.EMiMobileWebSocketClientDataReceived(Sender: TObject; procedure TdmWebsocket.EMiMobileWebSocketClientDataReceived(Sender: TObject;
Origin: string; SocketData: TJSObjectRecord); Origin: string; SocketData: TJSObjectRecord);
begin begin
if Sender <> EMiMobileWebSocketClient then
Exit;
// Text messages arrive via EMiMobileWebSocketClientMessageReceived. // Text messages arrive via EMiMobileWebSocketClientMessageReceived.
end; end;
procedure TdmWebsocket.EMiMobileWebSocketClientDisconnect(Sender: TObject); procedure TdmWebsocket.EMiMobileWebSocketClientDisconnect(Sender: TObject);
begin begin
if Sender <> EMiMobileWebSocketClient then
Exit;
console.log('WS: disconnected'); console.log('WS: disconnected');
CancelConnectTimeout;
CancelHeartbeatTimer;
CancelHeartbeatTimeout;
FConnecting := False;
FTransportMayBeActive := False;
FHeartbeatOutstanding := False;
if (not FStarted) or FDestroyingManager then
begin
SetConnectionState(wcsStopped);
Exit;
end;
if FPaused then
begin
SetConnectionState(wcsStopped);
Exit;
end;
if not BrowserIsOnline then
begin
SetConnectionState(wcsOffline);
Exit;
end;
FRecoveryPending := True;
ScheduleReconnect(False);
end; end;
procedure TdmWebsocket.EMiMobileWebSocketClientMessageReceived(Sender: TObject; procedure TdmWebsocket.EMiMobileWebSocketClientMessageReceived(Sender: TObject;
AMessage: string); AMessage: string);
begin begin
if Sender <> EMiMobileWebSocketClient then
Exit;
if not AuthenticationIsValid then
begin
HandleAuthenticationExpired;
Exit;
end;
NoteSocketActivity;
DispatchMessage(AMessage); DispatchMessage(AMessage);
end; end;
procedure TdmWebsocket.EnterPausedState;
begin
if FPaused then
Exit;
FPaused := True;
FRecoveryPending := FStarted;
FConnecting := False;
FHeartbeatOutstanding := False;
CancelAllTimers;
SetConnectionState(wcsStopped);
ReplaceSocket(True);
end;
procedure TdmWebsocket.HandleAuthenticationExpired;
var
notifyAuthenticationExpired: Boolean;
begin
notifyAuthenticationExpired := not FAuthenticationNotified;
FAuthenticationNotified := True;
FStarted := False;
FPaused := False;
FConnecting := False;
FHeartbeatOutstanding := False;
CancelAllTimers;
SetConnectionState(wcsStopped);
ReplaceSocket(False);
if notifyAuthenticationExpired and Assigned(FOnAuthenticationExpired) then
FOnAuthenticationExpired;
end;
procedure TdmWebsocket.HandleOffline(Event: TJSEvent);
begin
if (not FStarted) or FPaused then
Exit;
FRecoveryPending := True;
FConnecting := False;
FHeartbeatOutstanding := False;
CancelAllTimers;
SetConnectionState(wcsOffline);
ReplaceSocket(True);
end;
procedure TdmWebsocket.HandleOnline(Event: TJSEvent);
begin
if (not FStarted) or FPaused then
Exit;
if not AuthenticationIsValid then
begin
HandleAuthenticationExpired;
Exit;
end;
FRecoveryPending := True;
FReconnectAttempt := 0;
if FTransportMayBeActive then
RequestSocketRestart(True)
else
ScheduleReconnect(True);
end;
procedure TdmWebsocket.HandlePause(Event: TJSEvent);
begin
EnterPausedState;
end;
procedure TdmWebsocket.HandleResume(Event: TJSEvent);
begin
ResumeFromPausedState;
end;
procedure TdmWebsocket.HandleVisibilityChange(Event: TJSEvent);
begin
if DocumentIsHidden then
EnterPausedState
else
ResumeFromPausedState;
end;
function TdmWebsocket.NextReconnectDelayMs: Integer;
var
baseDelay: Integer;
begin
case FReconnectAttempt of
0: baseDelay := 1000;
1: baseDelay := 2000;
2: baseDelay := 5000;
3: baseDelay := 10000;
else
baseDelay := 30000;
end;
Inc(FReconnectAttempt);
Result := AddJitter(baseDelay);
end;
procedure TdmWebsocket.NoteSocketActivity;
begin
if FState <> wcsConnected then
Exit;
FHeartbeatOutstanding := False;
CancelHeartbeatTimeout;
ScheduleHeartbeat(HEARTBEAT_INTERVAL_MS);
end;
procedure TdmWebsocket.RegisterLifecycleListeners;
begin
if FLifecycleListenersRegistered then
Exit;
Document.addEventListener('pause', @HandlePause);
Document.addEventListener('resume', @HandleResume);
Document.addEventListener('visibilitychange', @HandleVisibilityChange);
window.addEventListener('online', @HandleOnline);
window.addEventListener('offline', @HandleOffline);
FLifecycleListenersRegistered := True;
end;
procedure TdmWebsocket.RequestSocketRestart(AImmediate: Boolean);
begin
if (not FStarted) or FPaused or FDestroyingManager then
Exit;
if not AuthenticationIsValid then
begin
HandleAuthenticationExpired;
Exit;
end;
if not BrowserIsOnline then
begin
SetConnectionState(wcsOffline);
Exit;
end;
FRecoveryPending := True;
FConnecting := False;
FHeartbeatOutstanding := False;
CancelConnectTimeout;
CancelHeartbeatTimer;
CancelHeartbeatTimeout;
SetConnectionState(wcsReconnecting);
// Detach and retire the current component before a replacement is opened.
// A late browser close from the old socket then has neither live handlers
// nor a matching Sender, so it cannot change the new connection's state.
ReplaceSocket(True);
ScheduleReconnect(AImmediate);
end;
procedure TdmWebsocket.ResumeFromPausedState;
begin
if not FStarted then
Exit;
FPaused := False;
FRecoveryPending := True;
FReconnectAttempt := 0;
if not AuthenticationIsValid then
begin
HandleAuthenticationExpired;
Exit;
end;
if not BrowserIsOnline then
begin
SetConnectionState(wcsOffline);
Exit;
end;
if FTransportMayBeActive then
RequestSocketRestart(True)
else
ScheduleReconnect(True);
end;
procedure TdmWebsocket.ScheduleHeartbeat(ADelayMs: Integer);
begin
CancelHeartbeatTimer;
if (not FStarted) or FPaused or (FState <> wcsConnected) then
Exit;
FHeartbeatTimerId := window.setTimeout(
procedure
begin
FHeartbeatTimerId := 0;
SendHeartbeat;
end,
ADelayMs);
end;
procedure TdmWebsocket.ScheduleReconnect(AImmediate: Boolean);
var
delayMs: Integer;
begin
if (not FStarted) or FPaused or FDestroyingManager then
Exit;
if not AuthenticationIsValid then
begin
HandleAuthenticationExpired;
Exit;
end;
if not BrowserIsOnline then
begin
SetConnectionState(wcsOffline);
Exit;
end;
if AImmediate then
CancelReconnectTimer
else if FReconnectTimerId <> 0 then
Exit;
if AImmediate then
delayMs := 0
else
delayMs := NextReconnectDelayMs;
if FAttemptedConnection then
SetConnectionState(wcsReconnecting)
else
SetConnectionState(wcsConnecting);
FReconnectTimerId := window.setTimeout(
procedure
begin
FReconnectTimerId := 0;
AttemptConnect;
end,
delayMs);
end;
procedure TdmWebsocket.SendHeartbeat;
var
msg: TJSObject;
begin
if (not FStarted) or FPaused or (FState <> wcsConnected) then
Exit;
if not AuthenticationIsValid then
begin
HandleAuthenticationExpired;
Exit;
end;
msg := TJSObject.new;
msg['message'] := 'heartbeat';
try
EMiMobileWebSocketClient.Send(TJSJSON.stringify(msg));
except
on E: Exception do
begin
console.log('WS: heartbeat send failed: ' + E.Message);
RequestSocketRestart(False);
Exit;
end;
end;
FHeartbeatOutstanding := True;
CancelHeartbeatTimeout;
FHeartbeatTimeoutId := window.setTimeout(
procedure
begin
FHeartbeatTimeoutId := 0;
if (not FStarted) or FPaused or
(FState <> wcsConnected) or (not FHeartbeatOutstanding) then
Exit;
console.log('WS: heartbeat timed out');
FHeartbeatOutstanding := False;
RequestSocketRestart(False);
end,
HEARTBEAT_TIMEOUT_MS);
end;
procedure TdmWebsocket.SendHeartbeatAck;
var
msg: TJSObject;
begin
if (not FStarted) or FPaused or (FState <> wcsConnected) then
Exit;
msg := TJSObject.new;
msg['message'] := 'heartbeat_ack';
try
EMiMobileWebSocketClient.Send(TJSJSON.stringify(msg));
except
on E: Exception do
begin
console.log('WS: heartbeat acknowledgement failed: ' + E.Message);
RequestSocketRestart(False);
end;
end;
end;
procedure TdmWebsocket.SetConnectionState(AState: TWsConnectionState);
begin
if FState = AState then
Exit;
FState := AState;
if (not FDestroyingManager) and Assigned(FOnStateChanged) then
FOnStateChanged(AState);
end;
procedure TdmWebsocket.Stop;
begin
FStarted := False;
FPaused := False;
FConnecting := False;
FRecoveryPending := False;
FHeartbeatOutstanding := False;
CancelAllTimers;
SetConnectionState(wcsStopped);
ReplaceSocket(False);
end;
procedure TdmWebsocket.UnregisterLifecycleListeners;
begin
if not FLifecycleListenersRegistered then
Exit;
Document.removeEventListener('pause', @HandlePause);
Document.removeEventListener('resume', @HandleResume);
Document.removeEventListener('visibilitychange', @HandleVisibilityChange);
window.removeEventListener('online', @HandleOnline);
window.removeEventListener('offline', @HandleOffline);
FLifecycleListenersRegistered := False;
end;
function TdmWebsocket.UrlEncode(const AValue: string): string;
begin
Result := '';
asm
Result = encodeURIComponent(AValue);
end;
end;
procedure TdmWebsocket.WebDataModuleCreate(Sender: TObject); procedure TdmWebsocket.WebDataModuleCreate(Sender: TObject);
begin begin
console.log('WS: datamodule created'); console.log('WS: datamodule created');
EMiMobileWebSocketClient.OnConnect := EMiMobileWebSocketClientConnect; FState := wcsStopped;
EMiMobileWebSocketClient.OnDisconnect := EMiMobileWebSocketClientDisconnect; FReconnectTimerId := 0;
EMiMobileWebSocketClient.OnMessageReceived := EMiMobileWebSocketClientMessageReceived; FConnectTimeoutId := 0;
FHeartbeatTimerId := 0;
FHeartbeatTimeoutId := 0;
AttachSocketEvents;
RegisterLifecycleListeners;
end; end;
procedure TdmWebsocket.WebDataModuleDestroy(Sender: TObject); procedure TdmWebsocket.WebDataModuleDestroy(Sender: TObject);
begin begin
FDestroyingManager := True;
UnregisterLifecycleListeners;
Stop;
FOnBadgeCounts := nil;
FOnUnitMap := nil;
FOnComplaintMap := nil;
FOnUnitList := nil;
FOnComplaintList := nil;
FOnStateChanged := nil;
FOnRecoveryRequired := nil;
FOnAuthenticationExpired := nil;
if dmWebsocket = Self then
dmWebsocket := nil;
end; end;
end. end.
...@@ -39,6 +39,8 @@ type ...@@ -39,6 +39,8 @@ type
FSelectProc: TSelectProc; FSelectProc: TSelectProc;
FLoading: Boolean; FLoading: Boolean;
FFirstLoad: Boolean; FFirstLoad: Boolean;
FRefreshPending: Boolean;
FPendingWsData: TJSObject;
[async] procedure GetComplaints; [async] procedure GetComplaints;
procedure HandleListClick(e: TJSMouseEvent); procedure HandleListClick(e: TJSMouseEvent);
procedure ShowHideBusinessRows; procedure ShowHideBusinessRows;
...@@ -59,6 +61,8 @@ procedure TFViewComplaints.WebFormCreate(Sender: TObject); ...@@ -59,6 +61,8 @@ procedure TFViewComplaints.WebFormCreate(Sender: TObject);
begin begin
Document.addEventListener('click', @HandleListClick); Document.addEventListener('click', @HandleListClick);
FFirstLoad := True; FFirstLoad := True;
FRefreshPending := False;
FPendingWsData := nil;
GetComplaints; GetComplaints;
asm asm
if (!window.showComplaintDetails) { if (!window.showComplaintDetails) {
...@@ -178,12 +182,32 @@ begin ...@@ -178,12 +182,32 @@ begin
HideSpinner('spinner'); HideSpinner('spinner');
FFirstLoad := False; FFirstLoad := False;
end; end;
if Assigned(FPendingWsData) then
begin
respObj := FPendingWsData;
FPendingWsData := nil;
ApplyWsData(respObj);
end;
if FRefreshPending then
begin
FRefreshPending := False;
GetComplaints;
end;
end; end;
end; end;
procedure TFViewComplaints.RefreshData; procedure TFViewComplaints.RefreshData;
begin begin
Console.Log('Complaints.RefreshData'); Console.Log('Complaints.RefreshData');
if FLoading then
begin
FRefreshPending := True;
Exit;
end;
GetComplaints; GetComplaints;
end; end;
...@@ -192,7 +216,10 @@ var ...@@ -192,7 +216,10 @@ var
complaintsCount: Integer; complaintsCount: Integer;
begin begin
if FLoading then if FLoading then
begin
FPendingWsData := aRespObj;
Exit; Exit;
end;
xdwdsComplaints.Close; xdwdsComplaints.Close;
xdwdsComplaints.SetJsonData(aRespObj['data']); xdwdsComplaints.SetJsonData(aRespObj['data']);
......
...@@ -11,12 +11,31 @@ ...@@ -11,12 +11,31 @@
<span id="lbl_main_title" class="navbar-brand text-light mb-0 ms-1"></span> <span id="lbl_main_title" class="navbar-brand text-light mb-0 ms-1"></span>
</div> </div>
<!-- Right: Connection / Logout --> <!-- Right: Connection / Menu -->
<div class="d-flex align-items-center gap-2 ms-auto"> <div class="d-flex align-items-center gap-2 ms-auto">
<span id="view.main.lblconnection" class="navbar-text text-light small"></span> <span id="view.main.lblconnection"
<span id="view.main.version" class="navbar-text text-light small opacity-75"></span> class="connection-status connection-status-connecting"
<button id="btn_logout" type="button" class="btn btn-outline-light btn-sm">Logout</button> role="status"
aria-live="polite"
aria-atomic="true"
aria-label="Live updates: Connecting"
title="Live updates: Connecting">
<span class="connection-status-dot" aria-hidden="true"></span>
<span id="view.main.lblconnectiontext" class="connection-status-text">Connecting</span>
</span>
<div class="dropdown">
<button type="button"
class="btn btn-outline-light btn-sm"
data-bs-toggle="dropdown"
aria-expanded="false"
aria-label="Menu">
<i class="fas fa-bars" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button id="btn_logout" type="button" class="dropdown-item">Logout</button></li>
</ul>
</div>
</div> </div>
</div> </div>
</nav> </nav>
......
...@@ -54,7 +54,11 @@ type ...@@ -54,7 +54,11 @@ type
FDetailsForm: TWebForm; FDetailsForm: TWebForm;
FArchiveForm: TWebForm; FArchiveForm: TWebForm;
FLogoutProc: TLogoutProc; FLogoutProc: TLogoutProc;
FBadgeRefreshInProgress: Boolean;
FBadgeRefreshPending: Boolean;
FPendingWsBadgeCounts: TJSObject;
[async] procedure RefreshBadgesAsync; [async] procedure RefreshBadgesAsync;
procedure ApplyBadgeCounts(aData: TJSObject);
procedure ShowUnitDetails(UnitId: string); procedure ShowUnitDetails(UnitId: string);
procedure SetHeaderTitle(const title: string); procedure SetHeaderTitle(const title: string);
procedure HideDetailsModal; procedure HideDetailsModal;
...@@ -68,6 +72,10 @@ type ...@@ -68,6 +72,10 @@ type
procedure HandleWsComplaintMap(aData: TJSObject); procedure HandleWsComplaintMap(aData: TJSObject);
procedure HandleWsUnitList(aData: TJSObject); procedure HandleWsUnitList(aData: TJSObject);
procedure HandleWsComplaintList(aData: TJSObject); procedure HandleWsComplaintList(aData: TJSObject);
procedure HandleWsConnectionState(AState: TWsConnectionState);
procedure HandleWsRecoveryRequired;
procedure HandleWsAuthenticationExpired;
procedure ResyncLiveData;
type TActivePanel = (apNone, apMap, apUnits, apComplaints); type TActivePanel = (apNone, apMap, apUnits, apComplaints);
var var
...@@ -84,6 +92,7 @@ type ...@@ -84,6 +92,7 @@ type
public public
{ Public declarations } { Public declarations }
destructor Destroy; override;
class procedure Display(LogoutProc: TLogoutProc); class procedure Display(LogoutProc: TLogoutProc);
procedure ShowForm( AFormClass: TWebFormClass ); procedure ShowForm( AFormClass: TWebFormClass );
procedure ShowComplaintDetails(ComplaintId: string); procedure ShowComplaintDetails(ComplaintId: string);
...@@ -121,15 +130,10 @@ const ...@@ -121,15 +130,10 @@ const
procedure TFViewMain.WebFormCreate(Sender: TObject); procedure TFViewMain.WebFormCreate(Sender: TObject);
var var
userName: string; userName: string;
el: TJSElement;
begin begin
userName := JS.toString(AuthService.TokenPayload.Properties['user_name']); userName := JS.toString(AuthService.TokenPayload.Properties['user_name']);
lblUsername.Caption := ' ' + userName.ToLower + ' '; lblUsername.Caption := ' ' + userName.ToLower + ' ';
el := Document.getElementById('view.main.version');
if Assigned(el) then
TJSHtmlElement(el).innerText := 'v' + TDMConnection.clientVersion;
FChildForm := nil; FChildForm := nil;
FDetailsForm := nil; FDetailsForm := nil;
FArchiveForm := nil; FArchiveForm := nil;
...@@ -139,6 +143,9 @@ begin ...@@ -139,6 +143,9 @@ begin
FMapRefreshTick := 0; FMapRefreshTick := 0;
FUnitsRefreshTick := 0; FUnitsRefreshTick := 0;
FComplaintsRefreshTick := 0; FComplaintsRefreshTick := 0;
FBadgeRefreshInProgress := False;
FBadgeRefreshPending := False;
FPendingWsBadgeCounts := nil;
if (not (JS.toBoolean(AuthService.TokenPayload.Properties['user_admin']))) then if (not (JS.toBoolean(AuthService.TokenPayload.Properties['user_admin']))) then
lblUsers.Visible := false; lblUsers.Visible := false;
...@@ -175,9 +182,27 @@ begin ...@@ -175,9 +182,27 @@ begin
dmWebsocket.OnComplaintMap := HandleWsComplaintMap; dmWebsocket.OnComplaintMap := HandleWsComplaintMap;
dmWebsocket.OnUnitList := HandleWsUnitList; dmWebsocket.OnUnitList := HandleWsUnitList;
dmWebsocket.OnComplaintList := HandleWsComplaintList; dmWebsocket.OnComplaintList := HandleWsComplaintList;
dmWebsocket.OnStateChanged := HandleWsConnectionState;
dmWebsocket.OnRecoveryRequired := HandleWsRecoveryRequired;
dmWebsocket.OnAuthenticationExpired := HandleWsAuthenticationExpired;
dmWebsocket.Connect(DMConnection.WsUrl); dmWebsocket.Connect(DMConnection.WsUrl);
end; end;
destructor TFViewMain.Destroy;
begin
if Assigned(dmWebsocket) and (dmWebsocket.Owner = Self) then
begin
dmWebsocket.Stop;
dmWebsocket.Free;
dmWebsocket := nil;
end;
if FViewMain = Self then
FViewMain := nil;
inherited;
end;
procedure TFViewMain.SetActivePanel(panel: TActivePanel); procedure TFViewMain.SetActivePanel(panel: TActivePanel);
begin begin
FActivePanel := panel; FActivePanel := panel;
...@@ -468,10 +493,13 @@ end; ...@@ -468,10 +493,13 @@ end;
// WebSocket push handlers // WebSocket push handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
procedure TFViewMain.HandleWsBadgeCounts(aData: TJSObject); procedure TFViewMain.ApplyBadgeCounts(aData: TJSObject);
var var
el: TJSElement; el: TJSElement;
begin begin
if not Assigned(aData) then
Exit;
el := Document.getElementById('view.main.badgecomplaints'); el := Document.getElementById('view.main.badgecomplaints');
if Assigned(el) then if Assigned(el) then
TJSHtmlElement(el).innerText := string(aData['BadgeComplaints']); TJSHtmlElement(el).innerText := string(aData['BadgeComplaints']);
...@@ -481,6 +509,17 @@ begin ...@@ -481,6 +509,17 @@ begin
TJSHtmlElement(el).innerText := string(aData['BadgeUnits']); TJSHtmlElement(el).innerText := string(aData['BadgeUnits']);
end; end;
procedure TFViewMain.HandleWsBadgeCounts(aData: TJSObject);
begin
if FBadgeRefreshInProgress then
begin
FPendingWsBadgeCounts := aData;
Exit;
end;
ApplyBadgeCounts(aData);
end;
procedure TFViewMain.HandleWsUnitMap(aData: TJSObject); procedure TFViewMain.HandleWsUnitMap(aData: TJSObject);
begin begin
if Assigned(FMapForm) then if Assigned(FMapForm) then
...@@ -505,6 +544,83 @@ begin ...@@ -505,6 +544,83 @@ begin
FComplaintsForm.ApplyWsData(aData); FComplaintsForm.ApplyWsData(aData);
end; end;
procedure TFViewMain.HandleWsConnectionState(AState: TWsConnectionState);
var
statusElement: TJSHTMLElement;
textElement: TJSHTMLElement;
statusText: string;
statusClass: string;
begin
case AState of
wcsConnecting:
begin
statusText := 'Connecting';
statusClass := 'connecting';
end;
wcsConnected:
begin
statusText := 'Connected';
statusClass := 'connected';
end;
wcsReconnecting:
begin
statusText := 'Reconnecting';
statusClass := 'reconnecting';
end;
wcsOffline:
begin
statusText := 'Offline';
statusClass := 'offline';
end;
else
begin
statusText := 'Disconnected';
statusClass := 'stopped';
end;
end;
statusElement := TJSHTMLElement(Document.getElementById('view.main.lblconnection'));
if Assigned(statusElement) then
begin
statusElement.setAttribute('class', 'connection-status connection-status-' + statusClass);
statusElement.setAttribute('aria-label', 'Live updates: ' + statusText);
statusElement.setAttribute('title', 'Live updates: ' + statusText);
end;
textElement := TJSHTMLElement(Document.getElementById('view.main.lblconnectiontext'));
if Assigned(textElement) then
textElement.innerText := statusText;
end;
procedure TFViewMain.HandleWsRecoveryRequired;
begin
ResyncLiveData;
end;
procedure TFViewMain.HandleWsAuthenticationExpired;
begin
if Assigned(dmWebsocket) then
dmWebsocket.Stop;
if Assigned(FLogoutProc) then
FLogoutProc('Your session has expired. Please sign in again.');
end;
procedure TFViewMain.ResyncLiveData;
begin
if (not AuthService.Authenticated) or AuthService.TokenExpired then
begin
HandleWsAuthenticationExpired;
Exit;
end;
Console.Log('WS: resynchronizing live data');
RefreshBadgesAsync;
Inc(FGlobalRefreshTick);
RefreshActivePanelFromTimer;
end;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
procedure TFViewMain.tmrBadgeCountsTimer(Sender: TObject); procedure TFViewMain.tmrBadgeCountsTimer(Sender: TObject);
...@@ -528,25 +644,52 @@ var ...@@ -528,25 +644,52 @@ var
badgeObj: TJSObject; badgeObj: TJSObject;
el: TJSElement; el: TJSElement;
begin begin
if FBadgeRefreshInProgress then
begin
FBadgeRefreshPending := True;
Exit;
end;
FBadgeRefreshInProgress := True;
try try
resp := await(xdwcBadgeCounts.RawInvokeAsync('IApiService.GetBadgeCounts', [])); try
badgeObj := TJSObject(resp.Result); resp := await(xdwcBadgeCounts.RawInvokeAsync('IApiService.GetBadgeCounts', []));
badgeObj := TJSObject(resp.Result);
el := Document.getElementById('view.main.badgecomplaints');
if Assigned(el) then if Assigned(FPendingWsBadgeCounts) then
TJSHtmlElement(el).innerText := string(badgeObj['BadgeComplaints']); begin
ApplyBadgeCounts(FPendingWsBadgeCounts);
el := Document.getElementById('view.main.badgeunits'); FPendingWsBadgeCounts := nil;
if Assigned(el) then end
TJSHtmlElement(el).innerText := string(badgeObj['BadgeUnits']); else
except ApplyBadgeCounts(badgeObj);
on E: Exception do except
on E: Exception do
begin
if Assigned(FPendingWsBadgeCounts) then
begin
ApplyBadgeCounts(FPendingWsBadgeCounts);
FPendingWsBadgeCounts := nil;
end
else
begin
el := Document.getElementById('view.main.badgecomplaints');
if Assigned(el) then TJSHtmlElement(el).innerText := '�';
el := Document.getElementById('view.main.badgeunits');
if Assigned(el) then TJSHtmlElement(el).innerText := '�';
end;
Console.Log('Badge refresh error: ' + E.Message);
end;
end;
finally
FBadgeRefreshInProgress := False;
if FBadgeRefreshPending then
begin begin
el := Document.getElementById('view.main.badgecomplaints'); FBadgeRefreshPending := False;
if Assigned(el) then TJSHtmlElement(el).innerText := '�'; RefreshBadgesAsync;
el := Document.getElementById('view.main.badgeunits');
if Assigned(el) then TJSHtmlElement(el).innerText := '�';
Console.Log('Badge refresh error: ' + E.Message);
end; end;
end; end;
end; end;
......
...@@ -62,7 +62,6 @@ object FViewMap: TFViewMap ...@@ -62,7 +62,6 @@ object FViewMap: TFViewMap
end end
object httpReqGeoJson: TWebHttpRequest object httpReqGeoJson: TWebHttpRequest
ResponseType = rtText ResponseType = rtText
URL = 'assets/orleanscounty.geojson'
OnResponse = httpReqGeoJsonResponse OnResponse = httpReqGeoJsonResponse
Left = 116 Left = 116
Top = 698 Top = 698
......
...@@ -52,16 +52,17 @@ ...@@ -52,16 +52,17 @@
<i class="fa fa-crosshairs"></i> <i class="fa fa-crosshairs"></i>
</button> </button>
<!-- Filters (top-right) --> <!-- Filters (bottom-right, above recenter) -->
<button id="btn_map_filters" <button id="btn_map_filters"
type="button" type="button"
class="btn btn-primary position-absolute top-0 end-0 m-2 shadow" class="btn btn-primary position-absolute end-0 me-2 shadow"
style="z-index:1000;" style="z-index:1000; bottom:4.5rem;"
data-bs-toggle="offcanvas" data-bs-toggle="offcanvas"
data-bs-target="#map_filters_offcanvas" data-bs-target="#map_filters_offcanvas"
aria-controls="map_filters_offcanvas"> aria-controls="map_filters_offcanvas"
<i class="fa fa-sliders-h"></i> aria-label="Map filters"
<span class="d-none d-sm-inline">Filters</span> title="Map filters">
<i class="fa fa-sliders-h" aria-hidden="true"></i>
</button> </button>
</div> </div>
</div> </div>
......
...@@ -31,6 +31,7 @@ type ...@@ -31,6 +31,7 @@ type
FUnitsLoaded: Boolean; FUnitsLoaded: Boolean;
FComplaintsLoaded: Boolean; FComplaintsLoaded: Boolean;
FLoadingPoints: Boolean; FLoadingPoints: Boolean;
FRefreshPending: Boolean;
mapFilters: TMapFilters; mapFilters: TMapFilters;
FPendingUnitId: string; FPendingUnitId: string;
FPendingComplaintId: string; FPendingComplaintId: string;
...@@ -293,10 +294,12 @@ begin ...@@ -293,10 +294,12 @@ begin
resp := await(xdwcMap.RawInvokeAsync('IApiService.GetUnitMap', [])); resp := await(xdwcMap.RawInvokeAsync('IApiService.GetUnitMap', []));
root := TJSObject(resp.Result); root := TJSObject(resp.Result);
unitsData := TJSArray(root['data']); unitsData := TJSArray(root['data']);
FUnitsLoaded := True; FUnitsLoaded := Assigned(unitsData);
except except
on E: EXDataClientRequestException do on E: EXDataClientRequestException do
Console.Log('Units XData error: ' + E.ErrorResult.ErrorMessage); Console.Log('Units XData error: ' + E.ErrorResult.ErrorMessage);
on E: Exception do
Console.Log('Units error: ' + E.Message);
end; end;
// --- Fetch Complaints ---------------------------------------------------- // --- Fetch Complaints ----------------------------------------------------
...@@ -304,10 +307,12 @@ begin ...@@ -304,10 +307,12 @@ begin
resp := await(xdwcMap.RawInvokeAsync('IApiService.GetComplaintMap', [])); resp := await(xdwcMap.RawInvokeAsync('IApiService.GetComplaintMap', []));
root := TJSObject(resp.Result); root := TJSObject(resp.Result);
complaintsData := TJSArray(root['data']); complaintsData := TJSArray(root['data']);
FComplaintsLoaded := True; FComplaintsLoaded := Assigned(complaintsData);
except except
on E: EXDataClientRequestException do on E: EXDataClientRequestException do
Console.Log('Complaints XData error: ' + E.ErrorResult.ErrorMessage); Console.Log('Complaints XData error: ' + E.ErrorResult.ErrorMessage);
on E: Exception do
Console.Log('Complaints error: ' + E.Message);
end; end;
// A WebSocket snapshot received while the HTTP requests were in flight is // A WebSocket snapshot received while the HTTP requests were in flight is
...@@ -329,8 +334,10 @@ begin ...@@ -329,8 +334,10 @@ begin
// --- Place markers (BeginUpdate wraps both so the map redraws once) ------ // --- Place markers (BeginUpdate wraps both so the map redraws once) ------
lfMap.BeginUpdate; lfMap.BeginUpdate;
try try
PlaceUnitMarkers(unitsData); if FUnitsLoaded then
PlaceComplaintMarkers(complaintsData); PlaceUnitMarkers(unitsData);
if FComplaintsLoaded then
PlaceComplaintMarkers(complaintsData);
finally finally
lfMap.EndUpdate; lfMap.EndUpdate;
end; end;
...@@ -344,6 +351,12 @@ begin ...@@ -344,6 +351,12 @@ begin
if showBusy then if showBusy then
HideSpinner('spinner'); HideSpinner('spinner');
FLoadingPoints := False; FLoadingPoints := False;
if FRefreshPending then
begin
FRefreshPending := False;
LoadPointsAsync(False);
end;
end; end;
end; end;
...@@ -716,22 +729,17 @@ begin ...@@ -716,22 +729,17 @@ begin
end; end;
procedure TFViewMap.btnFindLocationClick(Sender: TObject); procedure TFViewMap.btnFindLocationClick(Sender: TObject);
var
coord: TTMSFNCMapsCoordinateRec;
begin begin
if userLocationMarker = nil then tmrLocate.Enabled := False;
Exit; FDoFocusZoom := False;
coord := CreateCoordinate(userLocationMarker.Latitude, userLocationMarker.Longitude);
lfMap.SetCenterCoordinate(coord);
FPendingFocusCoord := coord;
FPendingFocusZoom := 17;
FDoFocusZoom := True;
FPendingFocusMarkerData := ''; FPendingFocusMarkerData := '';
FPendingUnitId := '';
FPendingComplaintId := '';
tmrLocate.Interval := 250; if lfMap.Polygons.Count = 0 then
tmrLocate.Enabled := True; Exit;
lfMap.ZoomToBounds(lfMap.Polygons.ToCoordinateArray);
end; end;
...@@ -855,6 +863,13 @@ end; ...@@ -855,6 +863,13 @@ end;
procedure TFViewMap.RefreshData; procedure TFViewMap.RefreshData;
begin begin
Console.Log('Map.RefreshData'); Console.Log('Map.RefreshData');
if FLoadingPoints then
begin
FRefreshPending := True;
Exit;
end;
LoadPointsAsync(False); LoadPointsAsync(False);
end; end;
......
...@@ -37,6 +37,8 @@ type ...@@ -37,6 +37,8 @@ type
private private
FLoading: Boolean; FLoading: Boolean;
FFirstLoad: Boolean; FFirstLoad: Boolean;
FRefreshPending: Boolean;
FPendingWsData: TJSObject;
[async] procedure GetUnits; [async] procedure GetUnits;
procedure HandleListClick(e: TJSMouseEvent); procedure HandleListClick(e: TJSMouseEvent);
public public
...@@ -57,6 +59,8 @@ begin ...@@ -57,6 +59,8 @@ begin
DMConnection.ApiConnection.Connected := True; DMConnection.ApiConnection.Connected := True;
Document.addEventListener('click', @HandleListClick); Document.addEventListener('click', @HandleListClick);
FFirstLoad := True; FFirstLoad := True;
FRefreshPending := False;
FPendingWsData := nil;
GetUnits; GetUnits;
asm asm
...@@ -158,12 +162,32 @@ begin ...@@ -158,12 +162,32 @@ begin
FFirstLoad := False; FFirstLoad := False;
FLoading := False; FLoading := False;
if Assigned(FPendingWsData) then
begin
respObj := FPendingWsData;
FPendingWsData := nil;
ApplyWsData(respObj);
end;
if FRefreshPending then
begin
FRefreshPending := False;
GetUnits;
end;
end; end;
end; end;
procedure TFViewUnits.RefreshData; procedure TFViewUnits.RefreshData;
begin begin
Console.Log('Units.RefreshData'); Console.Log('Units.RefreshData');
if FLoading then
begin
FRefreshPending := True;
Exit;
end;
GetUnits; GetUnits;
end; end;
...@@ -172,7 +196,10 @@ var ...@@ -172,7 +196,10 @@ var
unitCount: Integer; unitCount: Integer;
begin begin
if FLoading then if FLoading then
begin
FPendingWsData := aRespObj;
Exit; Exit;
end;
xdwdsUnits.Close; xdwdsUnits.Close;
xdwdsUnits.SetJsonData(aRespObj['data']); xdwdsUnits.SetJsonData(aRespObj['data']);
......
...@@ -82,6 +82,70 @@ html, body { ...@@ -82,6 +82,70 @@ html, body {
.tab-hidden { display: none !important; } .tab-hidden { display: none !important; }
.connection-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 1.75rem;
padding: 0.2rem 0.55rem;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 999px;
color: #fff;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
.connection-status-dot {
width: 0.55rem;
height: 0.55rem;
flex: 0 0 auto;
border-radius: 50%;
background-color: #adb5bd;
}
.connection-status-connected .connection-status-dot {
background-color: #75d995;
box-shadow: 0 0 0 0.14rem rgba(117, 217, 149, 0.2);
}
.connection-status-connecting .connection-status-dot,
.connection-status-reconnecting .connection-status-dot {
background-color: #ffd166;
animation: connection-status-pulse 1.4s ease-in-out infinite;
}
.connection-status-offline .connection-status-dot,
.connection-status-stopped .connection-status-dot {
background-color: #ff8b94;
}
@keyframes connection-status-pulse {
0%, 100% { opacity: 0.45; }
50% { opacity: 1; }
}
@media (max-width: 430px) {
.connection-status {
width: 1.75rem;
justify-content: center;
padding-right: 0;
padding-left: 0;
}
.connection-status-text {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.connection-status-connecting .connection-status-dot,
.connection-status-reconnecting .connection-status-dot {
animation: none;
}
}
.summary-chevron-icon { .summary-chevron-icon {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment