This commit is contained in:
2026-10-11 14:59:59 +07:00
parent f668eb66bc
commit 5980a5385e
6 changed files with 123 additions and 33 deletions
+46 -25
View File
@@ -44,17 +44,17 @@ service EveningDetectiveServer {
rpc DeviceHeartbeat(DeviceHeartbeatReq) returns (DeviceHeartbeatRsp) { rpc DeviceHeartbeat(DeviceHeartbeatReq) returns (DeviceHeartbeatRsp) {
option (google.api.http) = { post: "/api/devices/heartbeat" body: "*" }; option (google.api.http) = { post: "/api/devices/heartbeat" body: "*" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "Устройства"; tags : "Устройства";
summary: "Обновить присутствие подключения браузера"; summary : "Обновить присутствие подключения браузера";
description: "Публичный endpoint. Контексты взаимоисключающие и проверяются сервером. В prod enabled=false без регистрации. UUID обозначает браузерный профиль, не физический телефон."; description: "Публичный endpoint. Контексты взаимоисключающие и проверяются сервером. В prod enabled=false без регистрации. UUID обозначает браузерный профиль, не физический телефон.";
security: {}; security : {};
}; };
} }
rpc GetDevices(GetDevicesReq) returns (GetDevicesRsp) { rpc GetDevices(GetDevicesReq) returns (GetDevicesRsp) {
option (google.api.http) = { get: "/api/devices" }; option (google.api.http) = { get: "/api/devices" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags: "Устройства"; tags : "Устройства";
summary: "Получить устройства площадки"; summary : "Получить устройства площадки";
description: "Только venue; организатор, администратор или разработчик. Связь актуальна менее 60 секунд; подключения удаляются через 15 минут. Фильтр gameID оставляет только подключения указанной игры."; description: "Только venue; организатор, администратор или разработчик. Связь актуальна менее 60 секунд; подключения удаляются через 15 минут. Фильтр gameID оставляет только подключения указанной игры.";
}; };
} }
@@ -2401,39 +2401,60 @@ message GameResultsPackageRsp {
} }
message DeviceHeartbeatReq { message DeviceHeartbeatReq {
string deviceID = 1; string deviceID = 1;
string connectionID = 2; string connectionID = 2;
optional int32 organizerGameID = 3; optional int32 organizerGameID = 3;
optional int32 teamID = 4; optional int32 teamID = 4;
string password = 5; string password = 5;
string screenID = 6; string screenID = 6;
string accessToken = 7; string accessToken = 7;
} }
message DeviceHeartbeatRsp { message DeviceHeartbeatRsp {
optional bool enabled = 1; optional bool enabled = 1;
google.protobuf.Timestamp serverTime = 2; google.protobuf.Timestamp serverTime = 2;
} }
message GetDevicesReq { optional int32 gameID = 1; } message GetDevicesReq { optional int32 gameID = 1; }
message GetDevicesRsp { message GetDevicesRsp {
repeated VenueDevice devices = 1; repeated VenueDevice devices = 1;
google.protobuf.Timestamp serverTime = 2; google.protobuf.Timestamp serverTime = 2;
VenueWiFi wifi = 3; // Entire venue, independent of the game filter.
} }
message VenueDevice { message VenueDevice {
string ID = 1; string ID = 1;
google.protobuf.Timestamp firstSeenAt = 2; google.protobuf.Timestamp firstSeenAt = 2;
google.protobuf.Timestamp lastSeenAt = 3; google.protobuf.Timestamp lastSeenAt = 3;
bool connected = 4; bool connected = 4;
repeated DeviceConnection connections = 5; repeated DeviceConnection connections = 5;
} }
message DeviceConnection { message DeviceConnection {
string ID = 1; string ID = 1;
google.protobuf.Timestamp firstSeenAt = 2; google.protobuf.Timestamp firstSeenAt = 2;
google.protobuf.Timestamp lastSeenAt = 3; google.protobuf.Timestamp lastSeenAt = 3;
string IP = 4; string IP = 4;
string userAgent = 5; string userAgent = 5;
bool connected = 6; bool connected = 6;
optional int32 gameID = 7; optional int32 gameID = 7;
optional int32 teamID = 8; optional int32 teamID = 8;
optional int32 screenID = 9; optional int32 screenID = 9;
string kind = 10; // browser, organizer, team, screen string kind = 10; // browser, organizer, team, screen
}
message VenueWiFi {
bool enabled = 1;
google.protobuf.Timestamp collectedAt = 2;
bool stale = 3;
bool hasError = 4;
repeated WiFiClient clients = 5;
}
message WiFiClient {
string IP = 1;
string MAC = 2;
string band = 3;
optional int32 signal = 4; // dBm; absent when the router does not report it.
} }
@@ -1303,6 +1303,7 @@ export type GetDevicesReq = {
export type GetDevicesRsp = { export type GetDevicesRsp = {
devices: VenueDevice[] | undefined; devices: VenueDevice[] | undefined;
serverTime: wellKnownTimestamp | undefined; serverTime: wellKnownTimestamp | undefined;
wifi: VenueWiFi | undefined;
}; };
export type VenueDevice = { export type VenueDevice = {
@@ -1326,6 +1327,21 @@ export type DeviceConnection = {
kind: string | undefined; kind: string | undefined;
}; };
export type VenueWiFi = {
enabled: boolean | undefined;
collectedAt: wellKnownTimestamp | undefined;
stale: boolean | undefined;
hasError: boolean | undefined;
clients: WiFiClient[] | undefined;
};
export type WiFiClient = {
IP: string | undefined;
MAC: string | undefined;
band: string | undefined;
signal?: number;
};
export interface EveningDetectiveServer { export interface EveningDetectiveServer {
DeviceHeartbeat(request: DeviceHeartbeatReq): Promise<DeviceHeartbeatRsp>; DeviceHeartbeat(request: DeviceHeartbeatReq): Promise<DeviceHeartbeatRsp>;
GetDevices(request: GetDevicesReq): Promise<GetDevicesRsp>; GetDevices(request: GetDevicesReq): Promise<GetDevicesRsp>;
+6 -2
View File
@@ -136,7 +136,7 @@ const canViewState = computed(
) )
const isStateOnly = computed(() => canViewState.value && !canManageGame.value) const isStateOnly = computed(() => canViewState.value && !canManageGame.value)
useDeviceContext(() => (canManageGame.value ? { organizerGameID: Number(gameId) } : {})) useDeviceContext(() => (canManageGame.value ? { organizerGameID: Number(gameId) } : {}))
const { devices, serverTime, isLoading, hasError, summary, reload } = useGameState( const { devices, wifi, serverTime, isLoading, hasError, summary, reload } = useGameState(
() => Number(route.params.id), () => Number(route.params.id),
() => canViewState.value && activeTab.value === 'state', () => canViewState.value && activeTab.value === 'state',
) )
@@ -178,7 +178,10 @@ watch(
) )
const canExport = computed( const canExport = computed(
() => uiStore.environment === 'prod' && authStore.hasAnyRole(['admin', 'organizer', 'developer']), () =>
Number(game.value.ID) > 0 &&
uiStore.environment === 'prod' &&
authStore.hasAnyRole(['admin', 'organizer', 'developer']),
) )
const isStartingExport = ref(false) const isStartingExport = ref(false)
const isDownloadingPackage = ref(false) const isDownloadingPackage = ref(false)
@@ -1360,6 +1363,7 @@ useSimplePolling(() => {
> >
<GameStatePanel <GameStatePanel
:devices="devices" :devices="devices"
:wifi="wifi"
:server-time="serverTime" :server-time="serverTime"
:is-loading="isLoading" :is-loading="isLoading"
:has-error="hasError" :has-error="hasError"
+31 -1
View File
@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { NAlert, NButton, NCollapse, NCollapseItem, NFlex, NSpin, NTag } from 'naive-ui' import { NAlert, NButton, NCollapse, NCollapseItem, NFlex, NSpin, NTag } from 'naive-ui'
import type { VenueDevice } from '@/api/generated/crabs/evening_detective_server' import type { VenueDevice, VenueWiFi } from '@/api/generated/crabs/evening_detective_server'
import UiBlock from '@/components/UiBlock.vue' import UiBlock from '@/components/UiBlock.vue'
import { formatGameDateTime } from '@/utils/datetime' import { formatGameDateTime } from '@/utils/datetime'
defineProps<{ defineProps<{
devices: VenueDevice[] devices: VenueDevice[]
wifi?: VenueWiFi
serverTime?: string serverTime?: string
isLoading: boolean isLoading: boolean
hasError: boolean hasError: boolean
@@ -37,6 +38,35 @@ const kinds: Record<string, string> = {
<p v-else-if="!hasError && !serverTime && !isLoading">Загружаем состояние…</p> <p v-else-if="!hasError && !serverTime && !isLoading">Загружаем состояние…</p>
<p v-else-if="serverTime && !devices.length">Устройства этой игры пока не зарегистрированы</p> <p v-else-if="serverTime && !devices.length">Устройства этой игры пока не зарегистрированы</p>
</UiBlock> </UiBlock>
<UiBlock>
<h3>Wi-Fi площадки</h3>
<p>
Все Wi-Fi-клиенты Cudy, независимо от выбранной игры. MAC устройства и UUID браузера
различаются.
</p>
<p v-if="!wifi?.enabled">Сбор данных Cudy не настроен</p>
<template v-else>
<n-alert
v-if="wifi.hasError"
type="warning"
role="alert"
title="Не удалось обновить данные Cudy"
/>
<n-tag v-if="wifi.stale" type="warning">Данные устарели</n-tag>
<p v-if="wifi.collectedAt">
Последний снимок Cudy: {{ formatGameDateTime(wifi.collectedAt) }}
</p>
<template v-if="wifi.collectedAt">
<p>Wi-Fi-клиентов: {{ wifi.clients?.length || 0 }}</p>
<p v-if="!wifi.clients?.length">Wi-Fi-клиенты пока не подключены</p>
<div v-for="client in wifi.clients" :key="client.MAC" class="connection device-details">
<strong>{{ client.MAC }}</strong>
<p>IP: {{ client.IP || '—' }} · {{ client.band }}</p>
<p>Сигнал: {{ client.signal === undefined ? 'Нет данных' : `${client.signal} dBm` }}</p>
</div>
</template>
</template>
</UiBlock>
<UiBlock v-for="device in devices" :key="device.ID"> <UiBlock v-for="device in devices" :key="device.ID">
<div class="device-details"> <div class="device-details">
<strong>{{ device.ID }}</strong> <strong>{{ device.ID }}</strong>
+19 -3
View File
@@ -38,9 +38,22 @@ const settle = async () => {
await new Promise(setImmediate) await new Promise(setImmediate)
} }
const response = (devices: unknown[] = []) => const response = (devices: unknown[] = []) =>
new Response(JSON.stringify({ devices, serverTime: '2026-10-11T10:00:00Z' }), { new Response(
headers: { 'Content-Type': 'application/json' }, JSON.stringify({
}) devices,
serverTime: '2026-10-11T10:00:00Z',
wifi: {
enabled: true,
collectedAt: '2026-10-11T10:00:00Z',
stale: false,
hasError: false,
clients: [{ MAC: 'AA:BB:CC:DD:EE:01', signal: -65, band: '5 GHz' }],
},
}),
{
headers: { 'Content-Type': 'application/json' },
},
)
test('counts, sorting, polling, late responses and recovery', async () => { test('counts, sorting, polling, late responses and recovery', async () => {
const active = ref(false) const active = ref(false)
@@ -95,6 +108,7 @@ test('counts, sorting, polling, late responses and recovery', async () => {
state.devices.value.map((d: { ID: string }) => d.ID), state.devices.value.map((d: { ID: string }) => d.ID),
['a', 'z', 'b'], ['a', 'z', 'b'],
) )
assert.equal(state.wifi.value.clients[0].signal, -65)
assert.deepEqual(state.summary.value, { connected: 1, disconnected: 2, connections: 3 }) assert.deepEqual(state.summary.value, { connected: 1, disconnected: 2, connections: 3 })
globalThis.fetch = async () => { globalThis.fetch = async () => {
throw new Error('offline') throw new Error('offline')
@@ -103,6 +117,7 @@ test('counts, sorting, polling, late responses and recovery', async () => {
assert.equal(state.hasError.value, true) assert.equal(state.hasError.value, true)
assert.equal(state.devices.value.length, 3) assert.equal(state.devices.value.length, 3)
assert.ok(state.serverTime.value) assert.ok(state.serverTime.value)
assert.equal(state.wifi.value.clients[0].signal, -65)
globalThis.fetch = async () => response() globalThis.fetch = async () => response()
await state.reload() await state.reload()
assert.equal(state.hasError.value, false) assert.equal(state.hasError.value, false)
@@ -127,6 +142,7 @@ test('counts, sorting, polling, late responses and recovery', async () => {
oldResolve(response([{ ID: 'old game' }])) oldResolve(response([{ ID: 'old game' }]))
await settle() await settle()
assert.equal(state.devices.value.length, 0) assert.equal(state.devices.value.length, 0)
assert.equal(state.wifi.value, undefined)
resolve(response([{ ID: 'new game', connected: true }])) resolve(response([{ ID: 'new game', connected: true }]))
await settle() await settle()
assert.equal(state.devices.value[0].ID, 'new game') assert.equal(state.devices.value[0].ID, 'new game')
+5 -2
View File
@@ -1,12 +1,13 @@
import { computed, onScopeDispose, ref, watch } from 'vue' import { computed, onScopeDispose, ref, watch } from 'vue'
import { getAuthClient } from '@/api/auth_client' import { getAuthClient } from '@/api/auth_client'
import type { VenueDevice } from '@/api/generated/crabs/evening_detective_server' import type { VenueDevice, VenueWiFi } from '@/api/generated/crabs/evening_detective_server'
import { useSimplePolling } from '@/composables/useSimplePolling' import { useSimplePolling } from '@/composables/useSimplePolling'
export function useGameState(gameID: () => number, isActive: () => boolean) { export function useGameState(gameID: () => number, isActive: () => boolean) {
const client = getAuthClient() const client = getAuthClient()
const devices = ref<VenueDevice[]>([]) const devices = ref<VenueDevice[]>([])
const wifi = ref<VenueWiFi>()
const serverTime = ref<string>() const serverTime = ref<string>()
const isLoading = ref(false) const isLoading = ref(false)
const hasError = ref(false) const hasError = ref(false)
@@ -35,6 +36,7 @@ export function useGameState(gameID: () => number, isActive: () => boolean) {
(a, b) => (a, b) =>
Number(!!a.connected) - Number(!!b.connected) || (a.ID || '').localeCompare(b.ID || ''), Number(!!a.connected) - Number(!!b.connected) || (a.ID || '').localeCompare(b.ID || ''),
) )
wifi.value = result.wifi
serverTime.value = result.serverTime serverTime.value = result.serverTime
hasError.value = false hasError.value = false
} catch { } catch {
@@ -54,6 +56,7 @@ export function useGameState(gameID: () => number, isActive: () => boolean) {
isLoading.value = false isLoading.value = false
if (id !== previousID) { if (id !== previousID) {
devices.value = [] devices.value = []
wifi.value = undefined
serverTime.value = undefined serverTime.value = undefined
hasError.value = false hasError.value = false
} }
@@ -69,5 +72,5 @@ export function useGameState(gameID: () => number, isActive: () => boolean) {
version++ version++
}) })
return { devices, serverTime, isLoading, hasError, summary, reload } return { devices, wifi, serverTime, isLoading, hasError, summary, reload }
} }