feat(desktop): report current user online clients
This commit is contained in:
@@ -203,6 +203,7 @@ export interface RuntimeControllerSnapshot {
|
|||||||
lastPullStatus: 'idle' | 'success' | 'failed'
|
lastPullStatus: 'idle' | 'success' | 'failed'
|
||||||
lastAccountsSyncAt: number
|
lastAccountsSyncAt: number
|
||||||
lastServerTime: string | null
|
lastServerTime: string | null
|
||||||
|
onlineClientCount: number | null
|
||||||
lastError: string | null
|
lastError: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +252,7 @@ interface RuntimeState {
|
|||||||
lastPullStatus: 'idle' | 'success' | 'failed'
|
lastPullStatus: 'idle' | 'success' | 'failed'
|
||||||
lastAccountsSyncAt: number
|
lastAccountsSyncAt: number
|
||||||
lastServerTime: string | null
|
lastServerTime: string | null
|
||||||
|
onlineClientCount: number | null
|
||||||
lastError: string | null
|
lastError: string | null
|
||||||
activitySeq: number
|
activitySeq: number
|
||||||
}
|
}
|
||||||
@@ -280,6 +282,7 @@ const state: RuntimeState = {
|
|||||||
lastPullStatus: 'idle',
|
lastPullStatus: 'idle',
|
||||||
lastAccountsSyncAt: 0,
|
lastAccountsSyncAt: 0,
|
||||||
lastServerTime: null,
|
lastServerTime: null,
|
||||||
|
onlineClientCount: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
activitySeq: 0,
|
activitySeq: 0,
|
||||||
}
|
}
|
||||||
@@ -532,6 +535,7 @@ export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot {
|
|||||||
lastPullStatus: state.lastPullStatus,
|
lastPullStatus: state.lastPullStatus,
|
||||||
lastAccountsSyncAt: state.lastAccountsSyncAt,
|
lastAccountsSyncAt: state.lastAccountsSyncAt,
|
||||||
lastServerTime: state.lastServerTime,
|
lastServerTime: state.lastServerTime,
|
||||||
|
onlineClientCount: state.onlineClientCount,
|
||||||
lastError: state.lastError,
|
lastError: state.lastError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,6 +631,7 @@ function clearRuntimeState(): void {
|
|||||||
state.lastPullStatus = 'idle'
|
state.lastPullStatus = 'idle'
|
||||||
state.lastAccountsSyncAt = 0
|
state.lastAccountsSyncAt = 0
|
||||||
state.lastServerTime = null
|
state.lastServerTime = null
|
||||||
|
state.onlineClientCount = null
|
||||||
state.lastError = null
|
state.lastError = null
|
||||||
state.publishFallbackBackoffUntil = 0
|
state.publishFallbackBackoffUntil = 0
|
||||||
setSchedulerQueueDepth(localQueueDepth())
|
setSchedulerQueueDepth(localQueueDepth())
|
||||||
@@ -977,6 +982,8 @@ async function sendHeartbeat(source: 'startup' | 'timer'): Promise<void> {
|
|||||||
state.lastHeartbeatAt = Date.now()
|
state.lastHeartbeatAt = Date.now()
|
||||||
state.lastHeartbeatStatus = 'success'
|
state.lastHeartbeatStatus = 'success'
|
||||||
state.lastServerTime = response.server_time
|
state.lastServerTime = response.server_time
|
||||||
|
state.onlineClientCount =
|
||||||
|
response.current_user_online_client_count ?? response.online_client_count ?? null
|
||||||
state.lastError = null
|
state.lastError = null
|
||||||
|
|
||||||
noteTransportHeartbeat(true)
|
noteTransportHeartbeat(true)
|
||||||
|
|||||||
@@ -144,6 +144,10 @@ function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) {
|
|||||||
channel: controller.client?.channel ?? 'dev',
|
channel: controller.client?.channel ?? 'dev',
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
const onlineClientCount =
|
||||||
|
typeof controller.onlineClientCount === 'number'
|
||||||
|
? Math.max(0, controller.onlineClientCount)
|
||||||
|
: clients.filter((item) => item.status === 'online').length
|
||||||
|
|
||||||
const accounts = controller.accounts.map((account) =>
|
const accounts = controller.accounts.map((account) =>
|
||||||
createRuntimeAccountView(controller, account, {
|
createRuntimeAccountView(controller, account, {
|
||||||
@@ -185,7 +189,7 @@ function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) {
|
|||||||
lastFullSyncAt: controller.lastAccountsSyncAt || now,
|
lastFullSyncAt: controller.lastAccountsSyncAt || now,
|
||||||
},
|
},
|
||||||
summary: {
|
summary: {
|
||||||
onlineClients: clients.filter((item) => item.status === 'online').length,
|
onlineClients: onlineClientCount,
|
||||||
accountsBound: accounts.length,
|
accountsBound: accounts.length,
|
||||||
queuedTasks: tasks.filter((item) => item.status === 'queued').length,
|
queuedTasks: tasks.filter((item) => item.status === 'queued').length,
|
||||||
issuesOpen:
|
issuesOpen:
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const subsystemEntries = computed(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const clientCount = computed(() => snapshot.value?.clients.length ?? 0)
|
const clientCount = computed(() => snapshot.value?.summary.onlineClients ?? 0)
|
||||||
const appVersion = computed(() => snapshot.value?.app.version ?? '0.1.0')
|
const appVersion = computed(() => snapshot.value?.app.version ?? '0.1.0')
|
||||||
const hasServerSettingChanged = computed(
|
const hasServerSettingChanged = computed(
|
||||||
() => normalizeServerURLInput(serverBaseURL.value) !== apiBaseURL.value,
|
() => normalizeServerURLInput(serverBaseURL.value) !== apiBaseURL.value,
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ export interface DesktopClientHeartbeatRequest {
|
|||||||
export interface DesktopClientHeartbeatResponse {
|
export interface DesktopClientHeartbeatResponse {
|
||||||
client: DesktopClientInfo
|
client: DesktopClientInfo
|
||||||
server_time: string
|
server_time: string
|
||||||
|
online_client_count?: number
|
||||||
|
current_user_online_client_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DesktopClientRotateResponse {
|
export interface DesktopClientRotateResponse {
|
||||||
|
|||||||
@@ -84,8 +84,10 @@ type RotateDesktopClientResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HeartbeatDesktopClientResponse struct {
|
type HeartbeatDesktopClientResponse struct {
|
||||||
Client DesktopClientView `json:"client"`
|
Client DesktopClientView `json:"client"`
|
||||||
ServerTime time.Time `json:"server_time"`
|
ServerTime time.Time `json:"server_time"`
|
||||||
|
OnlineClientCount int `json:"online_client_count"`
|
||||||
|
CurrentUserOnlineClientCount int `json:"current_user_online_client_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OfflineDesktopClientResponse struct {
|
type OfflineDesktopClientResponse struct {
|
||||||
@@ -128,7 +130,7 @@ func (s *DesktopClientService) Register(ctx context.Context, actor auth.Actor, r
|
|||||||
return nil, response.ErrInternal(50072, "desktop_client_register_failed", "failed to persist desktop client")
|
return nil, response.ErrInternal(50072, "desktop_client_register_failed", "failed to persist desktop client")
|
||||||
}
|
}
|
||||||
|
|
||||||
markDesktopClientPresent(ctx, s.redis, client.ID)
|
markDesktopClientPresentForUser(ctx, s.redis, client.ID, client.TenantID, client.WorkspaceID, client.UserID)
|
||||||
|
|
||||||
return &RegisterDesktopClientResponse{
|
return &RegisterDesktopClientResponse{
|
||||||
ClientID: client.ID.String(),
|
ClientID: client.ID.String(),
|
||||||
@@ -160,7 +162,7 @@ func (s *DesktopClientService) Rotate(ctx context.Context, client *repository.De
|
|||||||
return nil, response.ErrInternal(50074, "desktop_client_rotate_failed", "failed to rotate desktop client token")
|
return nil, response.ErrInternal(50074, "desktop_client_rotate_failed", "failed to rotate desktop client token")
|
||||||
}
|
}
|
||||||
|
|
||||||
markDesktopClientPresent(ctx, s.redis, updated.ID)
|
markDesktopClientPresentForUser(ctx, s.redis, updated.ID, updated.TenantID, updated.WorkspaceID, updated.UserID)
|
||||||
|
|
||||||
return &RotateDesktopClientResponse{
|
return &RotateDesktopClientResponse{
|
||||||
ClientToken: rawToken,
|
ClientToken: rawToken,
|
||||||
@@ -190,8 +192,12 @@ func (s *DesktopClientService) Heartbeat(ctx context.Context, client *repository
|
|||||||
return nil, response.ErrInternal(50075, "desktop_client_heartbeat_failed", "failed to persist desktop client heartbeat")
|
return nil, response.ErrInternal(50075, "desktop_client_heartbeat_failed", "failed to persist desktop client heartbeat")
|
||||||
}
|
}
|
||||||
|
|
||||||
markDesktopClientPresent(ctx, s.redis, updated.ID)
|
markDesktopClientPresentForUser(ctx, s.redis, updated.ID, updated.TenantID, updated.WorkspaceID, updated.UserID)
|
||||||
markDesktopAccountsPresent(ctx, s.redis, updated.ID, parseDesktopAccountIDs(req.AccountIDs))
|
markDesktopAccountsPresent(ctx, s.redis, updated.ID, parseDesktopAccountIDs(req.AccountIDs))
|
||||||
|
onlineClientCount, err := s.currentUserOnlineClientCount(ctx, updated)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if req.Startup && s.taskSvc != nil {
|
if req.Startup && s.taskSvc != nil {
|
||||||
if err := s.taskSvc.recoverClientTasksOnStartup(ctx, updated); err != nil {
|
if err := s.taskSvc.recoverClientTasksOnStartup(ctx, updated); err != nil {
|
||||||
@@ -200,8 +206,10 @@ func (s *DesktopClientService) Heartbeat(ctx context.Context, client *repository
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &HeartbeatDesktopClientResponse{
|
return &HeartbeatDesktopClientResponse{
|
||||||
Client: buildDesktopClientView(updated),
|
Client: buildDesktopClientView(updated),
|
||||||
ServerTime: time.Now().UTC(),
|
ServerTime: time.Now().UTC(),
|
||||||
|
OnlineClientCount: onlineClientCount,
|
||||||
|
CurrentUserOnlineClientCount: onlineClientCount,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +226,7 @@ func (s *DesktopClientService) Revoke(ctx context.Context, client *repository.De
|
|||||||
return nil, response.ErrInternal(50076, "desktop_client_revoke_failed", "failed to revoke desktop client")
|
return nil, response.ErrInternal(50076, "desktop_client_revoke_failed", "failed to revoke desktop client")
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDesktopClientPresenceState(ctx, s.redis, updated.ID)
|
clearDesktopClientPresenceStateForUser(ctx, s.redis, updated.ID, updated.TenantID, updated.WorkspaceID, updated.UserID)
|
||||||
if s.taskSvc != nil {
|
if s.taskSvc != nil {
|
||||||
if err := s.taskSvc.recoverClientTasksOnDisconnect(ctx, updated); err != nil {
|
if err := s.taskSvc.recoverClientTasksOnDisconnect(ctx, updated); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -234,7 +242,7 @@ func (s *DesktopClientService) Offline(ctx context.Context, client *repository.D
|
|||||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
clearDesktopClientPresenceState(ctx, s.redis, client.ID)
|
clearDesktopClientPresenceStateForUser(ctx, s.redis, client.ID, client.TenantID, client.WorkspaceID, client.UserID)
|
||||||
if s.taskSvc != nil {
|
if s.taskSvc != nil {
|
||||||
if err := s.taskSvc.recoverClientTasksOnDisconnect(ctx, client); err != nil {
|
if err := s.taskSvc.recoverClientTasksOnDisconnect(ctx, client); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -246,6 +254,45 @@ func (s *DesktopClientService) Offline(ctx context.Context, client *repository.D
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *DesktopClientService) currentUserOnlineClientCount(ctx context.Context, client *repository.DesktopClient) (int, error) {
|
||||||
|
if count, ok := loadDesktopUserOnlineClientCount(ctx, s.redis, client.TenantID, client.WorkspaceID, client.UserID, time.Now().UTC()); ok {
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
clients, err := s.repo.ListByUser(ctx, client.TenantID, client.WorkspaceID, client.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, response.ErrInternal(50077, "desktop_client_online_count_failed", "failed to inspect online desktop clients")
|
||||||
|
}
|
||||||
|
|
||||||
|
return countOnlineDesktopClients(ctx, s.redis, clients, time.Now().UTC()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func countOnlineDesktopClients(ctx context.Context, redis *goredis.Client, clients []repository.DesktopClient, now time.Time) int {
|
||||||
|
if len(clients) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIDs := make([]uuid.UUID, 0, len(clients))
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.ID == uuid.Nil || client.RevokedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clientIDs = append(clientIDs, client.ID)
|
||||||
|
}
|
||||||
|
presence := loadDesktopClientPresence(ctx, redis, clientIDs)
|
||||||
|
|
||||||
|
onlineCount := 0
|
||||||
|
for _, client := range clients {
|
||||||
|
if client.ID == uuid.Nil || client.RevokedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resolveDesktopClientOnline(&client, presence[client.ID], presence != nil, now) {
|
||||||
|
onlineCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return onlineCount
|
||||||
|
}
|
||||||
|
|
||||||
func newDesktopClientToken() (string, []byte, error) {
|
func newDesktopClientToken() (string, []byte, error) {
|
||||||
raw := make([]byte, 32)
|
raw := make([]byte, 32)
|
||||||
if _, err := rand.Read(raw); err != nil {
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ func desktopClientAccountsPresenceKey(clientID uuid.UUID) string {
|
|||||||
return "desktop:presence:client_accounts:" + clientID.String()
|
return "desktop:presence:client_accounts:" + clientID.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func desktopUserClientsPresenceKey(tenantID, workspaceID, userID int64) string {
|
||||||
|
return "desktop:presence:user_clients:" +
|
||||||
|
strconv.FormatInt(tenantID, 10) + ":" +
|
||||||
|
strconv.FormatInt(workspaceID, 10) + ":" +
|
||||||
|
strconv.FormatInt(userID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
func markDesktopClientPresent(ctx context.Context, redis *goredis.Client, clientID uuid.UUID) {
|
func markDesktopClientPresent(ctx context.Context, redis *goredis.Client, clientID uuid.UUID) {
|
||||||
if redis == nil {
|
if redis == nil {
|
||||||
return
|
return
|
||||||
@@ -35,6 +42,25 @@ func markDesktopClientPresent(ctx context.Context, redis *goredis.Client, client
|
|||||||
_ = redis.Set(ctx, desktopClientPresenceKey(clientID), time.Now().UTC().Format(time.RFC3339), desktopClientPresenceTTL).Err()
|
_ = redis.Set(ctx, desktopClientPresenceKey(clientID), time.Now().UTC().Format(time.RFC3339), desktopClientPresenceTTL).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markDesktopClientPresentForUser(ctx context.Context, redis *goredis.Client, clientID uuid.UUID, tenantID, workspaceID, userID int64) {
|
||||||
|
markDesktopClientPresent(ctx, redis, clientID)
|
||||||
|
if redis == nil || clientID == uuid.Nil || tenantID == 0 || workspaceID == 0 || userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
staleBefore := strconv.FormatInt(now.Add(-desktopClientPresenceTTL).UnixMilli(), 10)
|
||||||
|
key := desktopUserClientsPresenceKey(tenantID, workspaceID, userID)
|
||||||
|
pipe := redis.Pipeline()
|
||||||
|
pipe.ZRemRangeByScore(ctx, key, "-inf", staleBefore)
|
||||||
|
pipe.ZAdd(ctx, key, goredis.Z{
|
||||||
|
Score: float64(now.UnixMilli()),
|
||||||
|
Member: clientID.String(),
|
||||||
|
})
|
||||||
|
pipe.Expire(ctx, key, desktopClientPresenceTTL*4)
|
||||||
|
_, _ = pipe.Exec(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func clearDesktopClientPresence(ctx context.Context, redis *goredis.Client, clientID uuid.UUID) {
|
func clearDesktopClientPresence(ctx context.Context, redis *goredis.Client, clientID uuid.UUID) {
|
||||||
if redis == nil {
|
if redis == nil {
|
||||||
return
|
return
|
||||||
@@ -43,6 +69,20 @@ func clearDesktopClientPresence(ctx context.Context, redis *goredis.Client, clie
|
|||||||
_ = redis.Del(ctx, desktopClientPresenceKey(clientID)).Err()
|
_ = redis.Del(ctx, desktopClientPresenceKey(clientID)).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearDesktopClientPresenceStateForUser(ctx context.Context, redis *goredis.Client, clientID uuid.UUID, tenantID, workspaceID, userID int64) {
|
||||||
|
clearDesktopClientPresenceState(ctx, redis, clientID)
|
||||||
|
if redis == nil || clientID == uuid.Nil || tenantID == 0 || workspaceID == 0 || userID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
staleBefore := strconv.FormatInt(time.Now().UTC().Add(-desktopClientPresenceTTL).UnixMilli(), 10)
|
||||||
|
key := desktopUserClientsPresenceKey(tenantID, workspaceID, userID)
|
||||||
|
pipe := redis.Pipeline()
|
||||||
|
pipe.ZRem(ctx, key, clientID.String())
|
||||||
|
pipe.ZRemRangeByScore(ctx, key, "-inf", staleBefore)
|
||||||
|
_, _ = pipe.Exec(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func loadDesktopClientPresence(ctx context.Context, redis *goredis.Client, clientIDs []uuid.UUID) map[uuid.UUID]bool {
|
func loadDesktopClientPresence(ctx context.Context, redis *goredis.Client, clientIDs []uuid.UUID) map[uuid.UUID]bool {
|
||||||
if redis == nil || len(clientIDs) == 0 {
|
if redis == nil || len(clientIDs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -75,6 +115,22 @@ func loadDesktopClientPresence(ctx context.Context, redis *goredis.Client, clien
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadDesktopUserOnlineClientCount(ctx context.Context, redis *goredis.Client, tenantID, workspaceID, userID int64, now time.Time) (int, bool) {
|
||||||
|
if redis == nil || tenantID == 0 || workspaceID == 0 || userID == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
staleBefore := strconv.FormatInt(now.UTC().Add(-desktopClientPresenceTTL).UnixMilli(), 10)
|
||||||
|
key := desktopUserClientsPresenceKey(tenantID, workspaceID, userID)
|
||||||
|
pipe := redis.Pipeline()
|
||||||
|
pipe.ZRemRangeByScore(ctx, key, "-inf", staleBefore)
|
||||||
|
countCmd := pipe.ZCard(ctx, key)
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil && err != goredis.Nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int(countCmd.Val()), true
|
||||||
|
}
|
||||||
|
|
||||||
func markDesktopAccountsPresent(ctx context.Context, redis *goredis.Client, clientID uuid.UUID, accountIDs []uuid.UUID) {
|
func markDesktopAccountsPresent(ctx context.Context, redis *goredis.Client, clientID uuid.UUID, accountIDs []uuid.UUID) {
|
||||||
if redis == nil {
|
if redis == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDesktopUserOnlineClientCountPrefersRedisPresenceIndex(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
server := miniredis.RunT(t)
|
||||||
|
redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, redis.Close())
|
||||||
|
})
|
||||||
|
|
||||||
|
tenantID := int64(7)
|
||||||
|
workspaceID := int64(11)
|
||||||
|
userID := int64(23)
|
||||||
|
firstClientID := uuid.New()
|
||||||
|
secondClientID := uuid.New()
|
||||||
|
|
||||||
|
markDesktopClientPresentForUser(ctx, redis, firstClientID, tenantID, workspaceID, userID)
|
||||||
|
markDesktopClientPresentForUser(ctx, redis, secondClientID, tenantID, workspaceID, userID)
|
||||||
|
|
||||||
|
count, ok := loadDesktopUserOnlineClientCount(ctx, redis, tenantID, workspaceID, userID, time.Now().UTC())
|
||||||
|
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 2, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDesktopClientServiceOnlineCountReturnsRedisCountBeforeRepoFallback(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
server := miniredis.RunT(t)
|
||||||
|
redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, redis.Close())
|
||||||
|
})
|
||||||
|
|
||||||
|
client := &repository.DesktopClient{
|
||||||
|
ID: uuid.New(),
|
||||||
|
TenantID: 7,
|
||||||
|
WorkspaceID: 11,
|
||||||
|
UserID: 23,
|
||||||
|
}
|
||||||
|
markDesktopClientPresentForUser(ctx, redis, client.ID, client.TenantID, client.WorkspaceID, client.UserID)
|
||||||
|
markDesktopClientPresentForUser(ctx, redis, uuid.New(), client.TenantID, client.WorkspaceID, client.UserID)
|
||||||
|
|
||||||
|
count, err := (&DesktopClientService{redis: redis}).currentUserOnlineClientCount(ctx, client)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDesktopUserOnlineClientCountPrunesStaleRedisPresence(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
server := miniredis.RunT(t)
|
||||||
|
redis := goredis.NewClient(&goredis.Options{Addr: server.Addr()})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
require.NoError(t, redis.Close())
|
||||||
|
})
|
||||||
|
|
||||||
|
tenantID := int64(7)
|
||||||
|
workspaceID := int64(11)
|
||||||
|
userID := int64(23)
|
||||||
|
key := desktopUserClientsPresenceKey(tenantID, workspaceID, userID)
|
||||||
|
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
require.NoError(t, redis.ZAdd(ctx, key,
|
||||||
|
goredis.Z{Score: float64(now.Add(-2 * desktopClientPresenceTTL).UnixMilli()), Member: uuid.NewString()},
|
||||||
|
goredis.Z{Score: float64(now.Add(-time.Second).UnixMilli()), Member: uuid.NewString()},
|
||||||
|
).Err())
|
||||||
|
|
||||||
|
count, ok := loadDesktopUserOnlineClientCount(ctx, redis, tenantID, workspaceID, userID, now)
|
||||||
|
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 1, count)
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ type DesktopClientRepository interface {
|
|||||||
Heartbeat(ctx context.Context, params HeartbeatDesktopClientParams) (*DesktopClient, error)
|
Heartbeat(ctx context.Context, params HeartbeatDesktopClientParams) (*DesktopClient, error)
|
||||||
Revoke(ctx context.Context, id uuid.UUID, workspaceID int64) (*DesktopClient, error)
|
Revoke(ctx context.Context, id uuid.UUID, workspaceID int64) (*DesktopClient, error)
|
||||||
ListByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error)
|
ListByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error)
|
||||||
|
ListByUser(ctx context.Context, tenantID, workspaceID, userID int64) ([]DesktopClient, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type desktopClientRepository struct {
|
type desktopClientRepository struct {
|
||||||
@@ -156,6 +157,23 @@ func (r *desktopClientRepository) ListByWorkspace(ctx context.Context, workspace
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return desktopClientsFromGenerated(rows), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *desktopClientRepository) ListByUser(ctx context.Context, tenantID, workspaceID, userID int64) ([]DesktopClient, error) {
|
||||||
|
rows, err := r.q.ListDesktopClientsByUser(ctx, generated.ListDesktopClientsByUserParams{
|
||||||
|
TenantID: tenantID,
|
||||||
|
WorkspaceID: workspaceID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return desktopClientsFromGenerated(rows), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func desktopClientsFromGenerated(rows []generated.DesktopClient) []DesktopClient {
|
||||||
items := make([]DesktopClient, 0, len(rows))
|
items := make([]DesktopClient, 0, len(rows))
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
item := desktopClientFromGenerated(row)
|
item := desktopClientFromGenerated(row)
|
||||||
@@ -163,7 +181,7 @@ func (r *desktopClientRepository) ListByWorkspace(ctx context.Context, workspace
|
|||||||
items = append(items, *item)
|
items = append(items, *item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return items, nil
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func desktopClientFromGenerated(row generated.DesktopClient) *DesktopClient {
|
func desktopClientFromGenerated(row generated.DesktopClient) *DesktopClient {
|
||||||
|
|||||||
@@ -131,6 +131,57 @@ func (q *Queries) HeartbeatDesktopClient(ctx context.Context, arg HeartbeatDeskt
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listDesktopClientsByUser = `-- name: ListDesktopClientsByUser :many
|
||||||
|
SELECT id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at
|
||||||
|
FROM desktop_clients
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND workspace_id = $2
|
||||||
|
AND user_id = $3
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
ORDER BY last_seen_at DESC NULLS LAST, created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListDesktopClientsByUserParams struct {
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
WorkspaceID int64 `json:"workspace_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListDesktopClientsByUser(ctx context.Context, arg ListDesktopClientsByUserParams) ([]DesktopClient, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listDesktopClientsByUser, arg.TenantID, arg.WorkspaceID, arg.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []DesktopClient
|
||||||
|
for rows.Next() {
|
||||||
|
var i DesktopClient
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.TenantID,
|
||||||
|
&i.WorkspaceID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.Os,
|
||||||
|
&i.CpuArch,
|
||||||
|
&i.ClientVersion,
|
||||||
|
&i.Channel,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.LastSeenAt,
|
||||||
|
&i.LastRotatedAt,
|
||||||
|
&i.RevokedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listDesktopClientsByWorkspace = `-- name: ListDesktopClientsByWorkspace :many
|
const listDesktopClientsByWorkspace = `-- name: ListDesktopClientsByWorkspace :many
|
||||||
SELECT id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at
|
SELECT id, tenant_id, workspace_id, user_id, token_hash, device_name, os, cpu_arch, client_version, channel, created_at, last_seen_at, last_rotated_at, revoked_at
|
||||||
FROM desktop_clients
|
FROM desktop_clients
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ type Querier interface {
|
|||||||
ListCompetitors(ctx context.Context, arg ListCompetitorsParams) ([]ListCompetitorsRow, error)
|
ListCompetitors(ctx context.Context, arg ListCompetitorsParams) ([]ListCompetitorsRow, error)
|
||||||
ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error)
|
ListDesktopAccountsByClient(ctx context.Context, arg ListDesktopAccountsByClientParams) ([]ListDesktopAccountsByClientRow, error)
|
||||||
ListDesktopAccountsByUser(ctx context.Context, arg ListDesktopAccountsByUserParams) ([]ListDesktopAccountsByUserRow, error)
|
ListDesktopAccountsByUser(ctx context.Context, arg ListDesktopAccountsByUserParams) ([]ListDesktopAccountsByUserRow, error)
|
||||||
|
ListDesktopClientsByUser(ctx context.Context, arg ListDesktopClientsByUserParams) ([]DesktopClient, error)
|
||||||
ListDesktopClientsByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error)
|
ListDesktopClientsByWorkspace(ctx context.Context, workspaceID int64) ([]DesktopClient, error)
|
||||||
ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error)
|
ListImageAssets(ctx context.Context, arg ListImageAssetsParams) ([]ListImageAssetsRow, error)
|
||||||
ListImageFolders(ctx context.Context, tenantID int64) ([]ListImageFoldersRow, error)
|
ListImageFolders(ctx context.Context, tenantID int64) ([]ListImageFoldersRow, error)
|
||||||
|
|||||||
@@ -89,3 +89,12 @@ FROM desktop_clients
|
|||||||
WHERE workspace_id = sqlc.arg(workspace_id)
|
WHERE workspace_id = sqlc.arg(workspace_id)
|
||||||
AND revoked_at IS NULL
|
AND revoked_at IS NULL
|
||||||
ORDER BY last_seen_at DESC NULLS LAST, created_at DESC;
|
ORDER BY last_seen_at DESC NULLS LAST, created_at DESC;
|
||||||
|
|
||||||
|
-- name: ListDesktopClientsByUser :many
|
||||||
|
SELECT *
|
||||||
|
FROM desktop_clients
|
||||||
|
WHERE tenant_id = sqlc.arg(tenant_id)
|
||||||
|
AND workspace_id = sqlc.arg(workspace_id)
|
||||||
|
AND user_id = sqlc.arg(user_id)
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
ORDER BY last_seen_at DESC NULLS LAST, created_at DESC;
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ func (s *stubDesktopClientRepo) ListByWorkspace(ctx context.Context, workspaceID
|
|||||||
panic("unexpected call")
|
panic("unexpected call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubDesktopClientRepo) ListByUser(ctx context.Context, tenantID, workspaceID, userID int64) ([]repository.DesktopClient, error) {
|
||||||
|
panic("unexpected call")
|
||||||
|
}
|
||||||
|
|
||||||
func TestDesktopClientMiddleware_RequiresBearerToken(t *testing.T) {
|
func TestDesktopClientMiddleware_RequiresBearerToken(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
|
|||||||
Reference in New Issue
Block a user