generated from VLADIMIR/template_frontend
up
This commit is contained in:
+46
-25
@@ -44,17 +44,17 @@ service EveningDetectiveServer {
|
||||
rpc DeviceHeartbeat(DeviceHeartbeatReq) returns (DeviceHeartbeatRsp) {
|
||||
option (google.api.http) = { post: "/api/devices/heartbeat" body: "*" };
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags: "Устройства";
|
||||
summary: "Обновить присутствие подключения браузера";
|
||||
tags : "Устройства";
|
||||
summary : "Обновить присутствие подключения браузера";
|
||||
description: "Публичный endpoint. Контексты взаимоисключающие и проверяются сервером. В prod enabled=false без регистрации. UUID обозначает браузерный профиль, не физический телефон.";
|
||||
security: {};
|
||||
security : {};
|
||||
};
|
||||
}
|
||||
rpc GetDevices(GetDevicesReq) returns (GetDevicesRsp) {
|
||||
option (google.api.http) = { get: "/api/devices" };
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||
tags: "Устройства";
|
||||
summary: "Получить устройства площадки";
|
||||
tags : "Устройства";
|
||||
summary : "Получить устройства площадки";
|
||||
description: "Только venue; организатор, администратор или разработчик. Связь актуальна менее 60 секунд; подключения удаляются через 15 минут. Фильтр gameID оставляет только подключения указанной игры.";
|
||||
};
|
||||
}
|
||||
@@ -2401,39 +2401,60 @@ message GameResultsPackageRsp {
|
||||
}
|
||||
|
||||
message DeviceHeartbeatReq {
|
||||
string deviceID = 1;
|
||||
string connectionID = 2;
|
||||
string deviceID = 1;
|
||||
string connectionID = 2;
|
||||
optional int32 organizerGameID = 3;
|
||||
optional int32 teamID = 4;
|
||||
string password = 5;
|
||||
string screenID = 6;
|
||||
string accessToken = 7;
|
||||
optional int32 teamID = 4;
|
||||
string password = 5;
|
||||
string screenID = 6;
|
||||
string accessToken = 7;
|
||||
}
|
||||
|
||||
message DeviceHeartbeatRsp {
|
||||
optional bool enabled = 1;
|
||||
optional bool enabled = 1;
|
||||
google.protobuf.Timestamp serverTime = 2;
|
||||
}
|
||||
|
||||
message GetDevicesReq { optional int32 gameID = 1; }
|
||||
|
||||
message GetDevicesRsp {
|
||||
repeated VenueDevice devices = 1;
|
||||
repeated VenueDevice devices = 1;
|
||||
google.protobuf.Timestamp serverTime = 2;
|
||||
VenueWiFi wifi = 3; // Entire venue, independent of the game filter.
|
||||
}
|
||||
|
||||
message VenueDevice {
|
||||
string ID = 1;
|
||||
string ID = 1;
|
||||
google.protobuf.Timestamp firstSeenAt = 2;
|
||||
google.protobuf.Timestamp lastSeenAt = 3;
|
||||
bool connected = 4;
|
||||
google.protobuf.Timestamp lastSeenAt = 3;
|
||||
bool connected = 4;
|
||||
repeated DeviceConnection connections = 5;
|
||||
}
|
||||
|
||||
message DeviceConnection {
|
||||
string ID = 1;
|
||||
string ID = 1;
|
||||
google.protobuf.Timestamp firstSeenAt = 2;
|
||||
google.protobuf.Timestamp lastSeenAt = 3;
|
||||
string IP = 4;
|
||||
string userAgent = 5;
|
||||
bool connected = 6;
|
||||
optional int32 gameID = 7;
|
||||
optional int32 teamID = 8;
|
||||
optional int32 screenID = 9;
|
||||
string kind = 10; // browser, organizer, team, screen
|
||||
google.protobuf.Timestamp lastSeenAt = 3;
|
||||
string IP = 4;
|
||||
string userAgent = 5;
|
||||
bool connected = 6;
|
||||
optional int32 gameID = 7;
|
||||
optional int32 teamID = 8;
|
||||
optional int32 screenID = 9;
|
||||
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 = {
|
||||
devices: VenueDevice[] | undefined;
|
||||
serverTime: wellKnownTimestamp | undefined;
|
||||
wifi: VenueWiFi | undefined;
|
||||
};
|
||||
|
||||
export type VenueDevice = {
|
||||
@@ -1326,6 +1327,21 @@ export type DeviceConnection = {
|
||||
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 {
|
||||
DeviceHeartbeat(request: DeviceHeartbeatReq): Promise<DeviceHeartbeatRsp>;
|
||||
GetDevices(request: GetDevicesReq): Promise<GetDevicesRsp>;
|
||||
|
||||
@@ -136,7 +136,7 @@ const canViewState = computed(
|
||||
)
|
||||
const isStateOnly = computed(() => canViewState.value && !canManageGame.value)
|
||||
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),
|
||||
() => canViewState.value && activeTab.value === 'state',
|
||||
)
|
||||
@@ -178,7 +178,10 @@ watch(
|
||||
)
|
||||
|
||||
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 isDownloadingPackage = ref(false)
|
||||
@@ -1360,6 +1363,7 @@ useSimplePolling(() => {
|
||||
>
|
||||
<GameStatePanel
|
||||
:devices="devices"
|
||||
:wifi="wifi"
|
||||
:server-time="serverTime"
|
||||
:is-loading="isLoading"
|
||||
:has-error="hasError"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
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 { formatGameDateTime } from '@/utils/datetime'
|
||||
|
||||
defineProps<{
|
||||
devices: VenueDevice[]
|
||||
wifi?: VenueWiFi
|
||||
serverTime?: string
|
||||
isLoading: boolean
|
||||
hasError: boolean
|
||||
@@ -37,6 +38,35 @@ const kinds: Record<string, string> = {
|
||||
<p v-else-if="!hasError && !serverTime && !isLoading">Загружаем состояние…</p>
|
||||
<p v-else-if="serverTime && !devices.length">Устройства этой игры пока не зарегистрированы</p>
|
||||
</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">
|
||||
<div class="device-details">
|
||||
<strong>{{ device.ID }}</strong>
|
||||
|
||||
@@ -38,9 +38,22 @@ const settle = async () => {
|
||||
await new Promise(setImmediate)
|
||||
}
|
||||
const response = (devices: unknown[] = []) =>
|
||||
new Response(JSON.stringify({ devices, serverTime: '2026-10-11T10:00:00Z' }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
new Response(
|
||||
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 () => {
|
||||
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),
|
||||
['a', 'z', 'b'],
|
||||
)
|
||||
assert.equal(state.wifi.value.clients[0].signal, -65)
|
||||
assert.deepEqual(state.summary.value, { connected: 1, disconnected: 2, connections: 3 })
|
||||
globalThis.fetch = async () => {
|
||||
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.devices.value.length, 3)
|
||||
assert.ok(state.serverTime.value)
|
||||
assert.equal(state.wifi.value.clients[0].signal, -65)
|
||||
globalThis.fetch = async () => response()
|
||||
await state.reload()
|
||||
assert.equal(state.hasError.value, false)
|
||||
@@ -127,6 +142,7 @@ test('counts, sorting, polling, late responses and recovery', async () => {
|
||||
oldResolve(response([{ ID: 'old game' }]))
|
||||
await settle()
|
||||
assert.equal(state.devices.value.length, 0)
|
||||
assert.equal(state.wifi.value, undefined)
|
||||
resolve(response([{ ID: 'new game', connected: true }]))
|
||||
await settle()
|
||||
assert.equal(state.devices.value[0].ID, 'new game')
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
|
||||
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'
|
||||
|
||||
export function useGameState(gameID: () => number, isActive: () => boolean) {
|
||||
const client = getAuthClient()
|
||||
const devices = ref<VenueDevice[]>([])
|
||||
const wifi = ref<VenueWiFi>()
|
||||
const serverTime = ref<string>()
|
||||
const isLoading = ref(false)
|
||||
const hasError = ref(false)
|
||||
@@ -35,6 +36,7 @@ export function useGameState(gameID: () => number, isActive: () => boolean) {
|
||||
(a, b) =>
|
||||
Number(!!a.connected) - Number(!!b.connected) || (a.ID || '').localeCompare(b.ID || ''),
|
||||
)
|
||||
wifi.value = result.wifi
|
||||
serverTime.value = result.serverTime
|
||||
hasError.value = false
|
||||
} catch {
|
||||
@@ -54,6 +56,7 @@ export function useGameState(gameID: () => number, isActive: () => boolean) {
|
||||
isLoading.value = false
|
||||
if (id !== previousID) {
|
||||
devices.value = []
|
||||
wifi.value = undefined
|
||||
serverTime.value = undefined
|
||||
hasError.value = false
|
||||
}
|
||||
@@ -69,5 +72,5 @@ export function useGameState(gameID: () => number, isActive: () => boolean) {
|
||||
version++
|
||||
})
|
||||
|
||||
return { devices, serverTime, isLoading, hasError, summary, reload }
|
||||
return { devices, wifi, serverTime, isLoading, hasError, summary, reload }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user