Compare commits
No commits in common. "6760759cb67ddd1efef86fb0c5067a8698c4c833" and "98aa1439e3a70bf97033e2f25749ab76e1bb16e8" have entirely different histories.
6760759cb6
...
98aa1439e3
14
.gitignore
vendored
@ -34,17 +34,3 @@ docker-compose.override.yml
|
|||||||
|
|
||||||
# TypeORM
|
# TypeORM
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|
||||||
#desktop
|
|
||||||
apps/robot-desktop/
|
|
||||||
apps/robot-desktop/src-tauri/target
|
|
||||||
apps/robot-desktop/src-tauri/target/
|
|
||||||
apps/robot-desktop/dist
|
|
||||||
apps/robot-client/node_modules
|
|
||||||
|
|
||||||
apps/robot-client-pi/
|
|
||||||
|
|
||||||
pi-snapshot/
|
|
||||||
|
|
||||||
|
|
||||||
.pio/
|
|
||||||
0
_tmp_945_290d62cdc9142d0226f30816d854ae86
Normal file
0
_tmp_945_eb782ccbcda1bcca02bbc45f41d4c1b8
Normal file
@ -27,7 +27,6 @@
|
|||||||
"@mastra/core": "^1.17.0",
|
"@mastra/core": "^1.17.0",
|
||||||
"@nestjs/common": "^11.1.17",
|
"@nestjs/common": "^11.1.17",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/event-emitter": "^3.0.0",
|
|
||||||
"@nestjs/core": "^11.1.17",
|
"@nestjs/core": "^11.1.17",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
import { Controller, Get, Param, Query, Inject, UseGuards } from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
import {
|
|
||||||
IHealthTelemetryPort,
|
|
||||||
HEALTH_TELEMETRY_PORT,
|
|
||||||
} from '../../../../core/ports/inbound/health-telemetry.port';
|
|
||||||
|
|
||||||
@Controller('devices/:deviceId/health')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
export class HealthController {
|
|
||||||
constructor(
|
|
||||||
@Inject(HEALTH_TELEMETRY_PORT)
|
|
||||||
private readonly healthTelemetry: IHealthTelemetryPort,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('reports')
|
|
||||||
async getReports(
|
|
||||||
@Param('deviceId') deviceId: string,
|
|
||||||
@Query('limit') limit?: string,
|
|
||||||
) {
|
|
||||||
const reports = await this.healthTelemetry.getLatestReports(
|
|
||||||
deviceId,
|
|
||||||
limit ? parseInt(limit, 10) : 20,
|
|
||||||
);
|
|
||||||
return { deviceId, reports };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('alerts')
|
|
||||||
async getAlerts(@Param('deviceId') deviceId: string) {
|
|
||||||
const alerts = await this.healthTelemetry.checkAlerts(deviceId);
|
|
||||||
return { deviceId, alerts, healthy: alerts.length === 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
import {
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Query,
|
|
||||||
Inject,
|
|
||||||
UseGuards,
|
|
||||||
Sse,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import { Observable, fromEvent, map, filter } from 'rxjs';
|
|
||||||
import {
|
|
||||||
ILogIngestionPort,
|
|
||||||
LOG_INGESTION_PORT,
|
|
||||||
} from '../../../../core/ports/inbound/log-ingestion.port';
|
|
||||||
|
|
||||||
/** Pino level number → human label */
|
|
||||||
const LEVEL_LABELS: Record<number, string> = {
|
|
||||||
10: 'trace',
|
|
||||||
20: 'debug',
|
|
||||||
30: 'info',
|
|
||||||
40: 'warn',
|
|
||||||
50: 'error',
|
|
||||||
60: 'fatal',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Controller()
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
export class LogsController {
|
|
||||||
constructor(
|
|
||||||
@Inject(LOG_INGESTION_PORT)
|
|
||||||
private readonly logIngestion: ILogIngestionPort,
|
|
||||||
private readonly events: EventEmitter2,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /devices/:deviceId/logs
|
|
||||||
* Query historical logs with filters.
|
|
||||||
*/
|
|
||||||
@Get('devices/:deviceId/logs')
|
|
||||||
async getLogs(
|
|
||||||
@Param('deviceId') deviceId: string,
|
|
||||||
@Query('level') level?: string,
|
|
||||||
@Query('logger') loggerName?: string,
|
|
||||||
@Query('since') since?: string,
|
|
||||||
@Query('until') until?: string,
|
|
||||||
@Query('search') search?: string,
|
|
||||||
@Query('limit') limit?: string,
|
|
||||||
@Query('offset') offset?: string,
|
|
||||||
) {
|
|
||||||
const result = await this.logIngestion.queryLogs({
|
|
||||||
deviceId,
|
|
||||||
level: level ? parseInt(level, 10) : undefined,
|
|
||||||
loggerName,
|
|
||||||
since,
|
|
||||||
until,
|
|
||||||
search,
|
|
||||||
limit: limit ? parseInt(limit, 10) : 50,
|
|
||||||
offset: offset ? parseInt(offset, 10) : 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
deviceId,
|
|
||||||
logs: result.logs.map((l) => ({
|
|
||||||
...l,
|
|
||||||
levelLabel: LEVEL_LABELS[l.level] ?? 'unknown',
|
|
||||||
})),
|
|
||||||
total: result.total,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /admin/logs/stream?deviceId=xxx
|
|
||||||
* Server-Sent Events (SSE) — real-time log stream.
|
|
||||||
*/
|
|
||||||
@Sse('admin/logs/stream')
|
|
||||||
streamLogs(@Query('deviceId') deviceId?: string): Observable<MessageEvent> {
|
|
||||||
return fromEvent(this.events, 'device.log').pipe(
|
|
||||||
filter((event: unknown) => {
|
|
||||||
if (!deviceId) return true;
|
|
||||||
return (event as { deviceId: string }).deviceId === deviceId;
|
|
||||||
}),
|
|
||||||
map((event: unknown) => {
|
|
||||||
const log = event as {
|
|
||||||
deviceId: string;
|
|
||||||
id: string;
|
|
||||||
level: number;
|
|
||||||
msg: string;
|
|
||||||
loggerName: string | null;
|
|
||||||
context: Record<string, unknown> | null;
|
|
||||||
loggedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
...log,
|
|
||||||
levelLabel: LEVEL_LABELS[log.level] ?? 'unknown',
|
|
||||||
},
|
|
||||||
} as MessageEvent;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import { IsString, IsNotEmpty, Length, Matches } from 'class-validator';
|
|
||||||
|
|
||||||
export class RequestPairingDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@Length(1, 100)
|
|
||||||
deviceName!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ConfirmPairingDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@Length(6, 6)
|
|
||||||
@Matches(/^\d{6}$/, { message: 'Pairing code must be 6 digits' })
|
|
||||||
code!: string;
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
import { Controller, Post, Get, Body, Param, UseGuards, Request } from '@nestjs/common';
|
|
||||||
import { PairingService } from '../../../../core/services/pairing.service';
|
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
||||||
import { RequestPairingDto, ConfirmPairingDto } from './dto/pairing.dto';
|
|
||||||
|
|
||||||
@Controller('pairing')
|
|
||||||
export class PairingController {
|
|
||||||
constructor(
|
|
||||||
private readonly pairingService: PairingService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /pairing/request
|
|
||||||
* Called by the robot on first boot. No auth required.
|
|
||||||
* Returns a requestId + 6-digit code for the robot to display.
|
|
||||||
*/
|
|
||||||
@Post('request')
|
|
||||||
async requestPairing(@Body() dto: RequestPairingDto) {
|
|
||||||
return this.pairingService.requestPairing(dto.deviceName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /pairing/status/:requestId
|
|
||||||
* Called by the robot to poll for confirmation. No auth required.
|
|
||||||
* Returns { status: 'pending' } or { status: 'confirmed', deviceId, deviceToken }.
|
|
||||||
*/
|
|
||||||
@Get('status/:requestId')
|
|
||||||
async getPairingStatus(@Param('requestId') requestId: string) {
|
|
||||||
const request = await this.pairingService.getPairingStatus(requestId);
|
|
||||||
|
|
||||||
if (request.status === 'pending') {
|
|
||||||
return { status: 'pending', code: request.code };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirmed — return credentials to the robot
|
|
||||||
return {
|
|
||||||
status: 'confirmed',
|
|
||||||
deviceId: request.deviceId,
|
|
||||||
deviceToken: request.deviceToken,
|
|
||||||
homeId: request.homeId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /pairing/confirm
|
|
||||||
* Called by the user's app with their JWT + the 6-digit code.
|
|
||||||
* Associates the robot to the user's home.
|
|
||||||
*/
|
|
||||||
@Post('confirm')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
async confirmPairing(
|
|
||||||
@Body() dto: ConfirmPairingDto,
|
|
||||||
@Request() req: { user: { homeId: string } },
|
|
||||||
) {
|
|
||||||
return this.pairingService.confirmPairing(dto.code, req.user.homeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -11,11 +11,8 @@ import { Inject, Logger, forwardRef } from '@nestjs/common';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import { DeviceService } from '../../../core/services/device.service';
|
import { DeviceService } from '../../../core/services/device.service';
|
||||||
import { DeviceStatus } from '../../../core/domain/entities/device.entity';
|
|
||||||
import { JwtPayload } from '../rest/auth/strategies/jwt.strategy';
|
import { JwtPayload } from '../rest/auth/strategies/jwt.strategy';
|
||||||
import { IConversationPort, CONVERSATION_PORT } from '../../../core/ports/inbound/conversation.port';
|
import { IConversationPort, CONVERSATION_PORT } from '../../../core/ports/inbound/conversation.port';
|
||||||
import { IHealthTelemetryPort, HEALTH_TELEMETRY_PORT, HealthReportPayload } from '../../../core/ports/inbound/health-telemetry.port';
|
|
||||||
import { ILogIngestionPort, LOG_INGESTION_PORT, LogBatchPayload } from '../../../core/ports/inbound/log-ingestion.port';
|
|
||||||
import { IDeviceGatewayPort } from '../../../core/ports/outbound/device-gateway.port';
|
import { IDeviceGatewayPort } from '../../../core/ports/outbound/device-gateway.port';
|
||||||
|
|
||||||
interface AuthenticatedSocket extends Socket {
|
interface AuthenticatedSocket extends Socket {
|
||||||
@ -47,8 +44,6 @@ export class RobotGateway implements OnGatewayConnection, OnGatewayDisconnect, I
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly deviceService: DeviceService,
|
private readonly deviceService: DeviceService,
|
||||||
@Inject(forwardRef(() => CONVERSATION_PORT)) private readonly conversationPort: IConversationPort,
|
@Inject(forwardRef(() => CONVERSATION_PORT)) private readonly conversationPort: IConversationPort,
|
||||||
@Inject(HEALTH_TELEMETRY_PORT) private readonly healthTelemetryPort: IHealthTelemetryPort,
|
|
||||||
@Inject(LOG_INGESTION_PORT) private readonly logIngestionPort: ILogIngestionPort,
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async handleConnection(client: AuthenticatedSocket) {
|
async handleConnection(client: AuthenticatedSocket) {
|
||||||
@ -81,7 +76,7 @@ export class RobotGateway implements OnGatewayConnection, OnGatewayDisconnect, I
|
|||||||
client.data.homeId = payload.homeId;
|
client.data.homeId = payload.homeId;
|
||||||
|
|
||||||
this.connectedDevices.set(payload.sub, client);
|
this.connectedDevices.set(payload.sub, client);
|
||||||
await this.deviceService.setStatus(payload.sub, DeviceStatus.ONLINE);
|
await this.deviceService.updateLastSeen(payload.sub);
|
||||||
|
|
||||||
this.logger.log(`Device connected: ${device.name} (${payload.sub})`);
|
this.logger.log(`Device connected: ${device.name} (${payload.sub})`);
|
||||||
client.emit('status', { state: 'idle' as RobotState });
|
client.emit('status', { state: 'idle' as RobotState });
|
||||||
@ -91,16 +86,11 @@ export class RobotGateway implements OnGatewayConnection, OnGatewayDisconnect, I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDisconnect(client: AuthenticatedSocket) {
|
handleDisconnect(client: AuthenticatedSocket) {
|
||||||
const deviceId = client.data?.deviceId;
|
const deviceId = client.data?.deviceId;
|
||||||
if (deviceId) {
|
if (deviceId) {
|
||||||
this.connectedDevices.delete(deviceId);
|
this.connectedDevices.delete(deviceId);
|
||||||
this.conversationPort.interrupt(deviceId);
|
this.conversationPort.interrupt(deviceId);
|
||||||
try {
|
|
||||||
await this.deviceService.setStatus(deviceId, DeviceStatus.OFFLINE);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to mark ${deviceId} offline: ${String(err)}`);
|
|
||||||
}
|
|
||||||
this.logger.log(`Device disconnected: ${deviceId}`);
|
this.logger.log(`Device disconnected: ${deviceId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,12 +98,8 @@ export class RobotGateway implements OnGatewayConnection, OnGatewayDisconnect, I
|
|||||||
@SubscribeMessage('wake_word_detected')
|
@SubscribeMessage('wake_word_detected')
|
||||||
async handleWakeWord(@ConnectedSocket() client: AuthenticatedSocket) {
|
async handleWakeWord(@ConnectedSocket() client: AuthenticatedSocket) {
|
||||||
this.logger.log(`Wake word detected on device ${client.data.deviceId}`);
|
this.logger.log(`Wake word detected on device ${client.data.deviceId}`);
|
||||||
// IMPORTANT: open the STT stream FIRST, then tell the client we're
|
|
||||||
// listening. The client flushes its buffered audio as soon as it
|
|
||||||
// sees `listening`, so if Deepgram isn't open yet those chunks are
|
|
||||||
// silently dropped with a "No active STT stream" warning.
|
|
||||||
await this.conversationPort.startListening(client.data.deviceId);
|
|
||||||
client.emit('status', { state: 'listening' as RobotState });
|
client.emit('status', { state: 'listening' as RobotState });
|
||||||
|
await this.conversationPort.startListening(client.data.deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('audio_chunk')
|
@SubscribeMessage('audio_chunk')
|
||||||
@ -137,22 +123,6 @@ export class RobotGateway implements OnGatewayConnection, OnGatewayDisconnect, I
|
|||||||
client.emit('status', { state: 'listening' as RobotState });
|
client.emit('status', { state: 'listening' as RobotState });
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('health_report')
|
|
||||||
async handleHealthReport(
|
|
||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
|
||||||
@MessageBody() payload: HealthReportPayload,
|
|
||||||
) {
|
|
||||||
await this.healthTelemetryPort.ingestReport(client.data.deviceId, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
@SubscribeMessage('log_batch')
|
|
||||||
async handleLogBatch(
|
|
||||||
@ConnectedSocket() client: AuthenticatedSocket,
|
|
||||||
@MessageBody() payload: LogBatchPayload,
|
|
||||||
) {
|
|
||||||
await this.logIngestionPort.ingestBatch(client.data.deviceId, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
sendAudioChunk(deviceId: string, chunk: Buffer) {
|
sendAudioChunk(deviceId: string, chunk: Buffer) {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
@ -12,8 +11,6 @@ import { authConfig } from './config/auth.config';
|
|||||||
import { Home } from './core/domain/entities/home.entity';
|
import { Home } from './core/domain/entities/home.entity';
|
||||||
import { User } from './core/domain/entities/user.entity';
|
import { User } from './core/domain/entities/user.entity';
|
||||||
import { Device } from './core/domain/entities/device.entity';
|
import { Device } from './core/domain/entities/device.entity';
|
||||||
import { HealthReport } from './core/domain/entities/health-report.entity';
|
|
||||||
import { DeviceLog } from './core/domain/entities/device-log.entity';
|
|
||||||
import { AuthService } from './core/services/auth.service';
|
import { AuthService } from './core/services/auth.service';
|
||||||
import { UserService } from './core/services/user.service';
|
import { UserService } from './core/services/user.service';
|
||||||
import { HomeService } from './core/services/home.service';
|
import { HomeService } from './core/services/home.service';
|
||||||
@ -22,10 +19,6 @@ import { ConversationService } from './core/services/conversation.service';
|
|||||||
import { JwtStrategy } from './adapters/inbound/rest/auth/strategies/jwt.strategy';
|
import { JwtStrategy } from './adapters/inbound/rest/auth/strategies/jwt.strategy';
|
||||||
import { AuthController } from './adapters/inbound/rest/auth/auth.controller';
|
import { AuthController } from './adapters/inbound/rest/auth/auth.controller';
|
||||||
import { DeviceController } from './adapters/inbound/rest/device/device.controller';
|
import { DeviceController } from './adapters/inbound/rest/device/device.controller';
|
||||||
import { PairingController } from './adapters/inbound/rest/pairing/pairing.controller';
|
|
||||||
import { HealthController } from './adapters/inbound/rest/health/health.controller';
|
|
||||||
import { LogsController } from './adapters/inbound/rest/logs/logs.controller';
|
|
||||||
import { PairingService } from './core/services/pairing.service';
|
|
||||||
import { RobotGateway } from './adapters/inbound/websocket/robot.gateway';
|
import { RobotGateway } from './adapters/inbound/websocket/robot.gateway';
|
||||||
import { DeepgramAdapter } from './adapters/outbound/stt/deepgram.adapter';
|
import { DeepgramAdapter } from './adapters/outbound/stt/deepgram.adapter';
|
||||||
import { AnthropicAdapter } from './adapters/outbound/llm/anthropic.adapter';
|
import { AnthropicAdapter } from './adapters/outbound/llm/anthropic.adapter';
|
||||||
@ -33,10 +26,6 @@ import { OpenAIAdapter } from './adapters/outbound/llm/openai.adapter';
|
|||||||
import { ElevenLabsAdapter } from './adapters/outbound/tts/elevenlabs.adapter';
|
import { ElevenLabsAdapter } from './adapters/outbound/tts/elevenlabs.adapter';
|
||||||
import { RedisAdapter } from './adapters/outbound/cache/redis.adapter';
|
import { RedisAdapter } from './adapters/outbound/cache/redis.adapter';
|
||||||
import { CONVERSATION_PORT } from './core/ports/inbound/conversation.port';
|
import { CONVERSATION_PORT } from './core/ports/inbound/conversation.port';
|
||||||
import { HEALTH_TELEMETRY_PORT } from './core/ports/inbound/health-telemetry.port';
|
|
||||||
import { HealthTelemetryService } from './core/services/health-telemetry.service';
|
|
||||||
import { LOG_INGESTION_PORT } from './core/ports/inbound/log-ingestion.port';
|
|
||||||
import { LogIngestionService } from './core/services/log-ingestion.service';
|
|
||||||
import { STT_PORT } from './core/ports/outbound/stt.port';
|
import { STT_PORT } from './core/ports/outbound/stt.port';
|
||||||
import { LLM_PORT } from './core/ports/outbound/llm.port';
|
import { LLM_PORT } from './core/ports/outbound/llm.port';
|
||||||
import { TTS_PORT } from './core/ports/outbound/tts.port';
|
import { TTS_PORT } from './core/ports/outbound/tts.port';
|
||||||
@ -55,8 +44,7 @@ import { CACHE_PORT } from './core/ports/outbound/cache.port';
|
|||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (configService: ConfigService) => typeormConfig(configService),
|
useFactory: (configService: ConfigService) => typeormConfig(configService),
|
||||||
}),
|
}),
|
||||||
EventEmitterModule.forRoot(),
|
TypeOrmModule.forFeature([Home, User, Device]),
|
||||||
TypeOrmModule.forFeature([Home, User, Device, HealthReport, DeviceLog]),
|
|
||||||
PassportModule,
|
PassportModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
@ -66,25 +54,14 @@ import { CACHE_PORT } from './core/ports/outbound/cache.port';
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController, DeviceController, PairingController, HealthController, LogsController],
|
controllers: [AuthController, DeviceController],
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
UserService,
|
UserService,
|
||||||
HomeService,
|
HomeService,
|
||||||
DeviceService,
|
DeviceService,
|
||||||
PairingService,
|
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
RobotGateway,
|
RobotGateway,
|
||||||
HealthTelemetryService,
|
|
||||||
LogIngestionService,
|
|
||||||
{
|
|
||||||
provide: HEALTH_TELEMETRY_PORT,
|
|
||||||
useExisting: HealthTelemetryService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: LOG_INGESTION_PORT,
|
|
||||||
useExisting: LogIngestionService,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provide: CONVERSATION_PORT,
|
provide: CONVERSATION_PORT,
|
||||||
useClass: ConversationService,
|
useClass: ConversationService,
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
ManyToOne,
|
|
||||||
JoinColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { Device } from './device.entity';
|
|
||||||
|
|
||||||
@Entity('device_logs')
|
|
||||||
@Index(['deviceId', 'loggedAt'])
|
|
||||||
@Index(['deviceId', 'level'])
|
|
||||||
export class DeviceLog {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'uuid', name: 'device_id' })
|
|
||||||
deviceId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Device, { onDelete: 'CASCADE' })
|
|
||||||
@JoinColumn({ name: 'device_id' })
|
|
||||||
device!: Device;
|
|
||||||
|
|
||||||
/** Pino log level (10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal) */
|
|
||||||
@Column({ type: 'smallint' })
|
|
||||||
level!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'text' })
|
|
||||||
msg!: string;
|
|
||||||
|
|
||||||
/** Logger name (e.g. "orchestrator", "cloud-socket") */
|
|
||||||
@Column({ type: 'varchar', length: 64, nullable: true, name: 'logger_name' })
|
|
||||||
loggerName!: string | null;
|
|
||||||
|
|
||||||
/** Extra context fields (serialized) */
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
|
||||||
context!: Record<string, unknown> | null;
|
|
||||||
|
|
||||||
/** Original timestamp from the robot (client-side time) */
|
|
||||||
@Column({ type: 'timestamptz', name: 'logged_at' })
|
|
||||||
loggedAt!: Date;
|
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' })
|
|
||||||
createdAt!: Date;
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import {
|
|
||||||
Entity,
|
|
||||||
PrimaryGeneratedColumn,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
ManyToOne,
|
|
||||||
JoinColumn,
|
|
||||||
Index,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { Device } from './device.entity';
|
|
||||||
|
|
||||||
@Entity('health_reports')
|
|
||||||
@Index(['deviceId', 'createdAt'])
|
|
||||||
export class HealthReport {
|
|
||||||
@PrimaryGeneratedColumn('uuid')
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'uuid', name: 'device_id' })
|
|
||||||
deviceId!: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => Device, { onDelete: 'CASCADE' })
|
|
||||||
@JoinColumn({ name: 'device_id' })
|
|
||||||
device!: Device;
|
|
||||||
|
|
||||||
// ── System metrics ──
|
|
||||||
|
|
||||||
@Column({ type: 'real', name: 'cpu_temp_celsius' })
|
|
||||||
cpuTempCelsius!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'real', name: 'memory_used_mb' })
|
|
||||||
memoryUsedMb!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'real', name: 'memory_total_mb' })
|
|
||||||
memoryTotalMb!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'real', name: 'disk_used_percent' })
|
|
||||||
diskUsedPercent!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'real', name: 'load_avg_1m' })
|
|
||||||
loadAvg1m!: number;
|
|
||||||
|
|
||||||
// ── Process metrics ──
|
|
||||||
|
|
||||||
@Column({ type: 'real', name: 'heap_used_mb' })
|
|
||||||
heapUsedMb!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'real', name: 'heap_total_mb' })
|
|
||||||
heapTotalMb!: number;
|
|
||||||
|
|
||||||
@Column({ type: 'integer', name: 'uptime_seconds' })
|
|
||||||
uptimeSeconds!: number;
|
|
||||||
|
|
||||||
// ── Connectivity ──
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 64, nullable: true, name: 'wifi_ssid' })
|
|
||||||
wifiSsid!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'smallint', nullable: true, name: 'wifi_signal_dbm' })
|
|
||||||
wifiSignalDbm!: number | null;
|
|
||||||
|
|
||||||
// ── Firmware / version ──
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 20, name: 'client_version' })
|
|
||||||
clientVersion!: string;
|
|
||||||
|
|
||||||
@Column({ type: 'varchar', length: 30, name: 'node_version' })
|
|
||||||
nodeVersion!: string;
|
|
||||||
|
|
||||||
// ── Timestamps ──
|
|
||||||
|
|
||||||
@Column({ type: 'timestamptz', name: 'reported_at' })
|
|
||||||
reportedAt!: Date;
|
|
||||||
|
|
||||||
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' })
|
|
||||||
createdAt!: Date;
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
export interface HealthReportPayload {
|
|
||||||
cpuTempCelsius: number;
|
|
||||||
memoryUsedMb: number;
|
|
||||||
memoryTotalMb: number;
|
|
||||||
diskUsedPercent: number;
|
|
||||||
loadAvg1m: number;
|
|
||||||
heapUsedMb: number;
|
|
||||||
heapTotalMb: number;
|
|
||||||
uptimeSeconds: number;
|
|
||||||
wifiSsid: string | null;
|
|
||||||
wifiSignalDbm: number | null;
|
|
||||||
clientVersion: string;
|
|
||||||
nodeVersion: string;
|
|
||||||
reportedAt: string; // ISO 8601
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IHealthTelemetryPort {
|
|
||||||
/**
|
|
||||||
* Ingest a health report from a connected device.
|
|
||||||
*/
|
|
||||||
ingestReport(deviceId: string, payload: HealthReportPayload): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve the latest N reports for a device.
|
|
||||||
*/
|
|
||||||
getLatestReports(deviceId: string, limit?: number): Promise<HealthReportPayload[]>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a device has critical health issues.
|
|
||||||
* Returns warning messages, or empty array if healthy.
|
|
||||||
*/
|
|
||||||
checkAlerts(deviceId: string): Promise<string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HEALTH_TELEMETRY_PORT = Symbol('HEALTH_TELEMETRY_PORT');
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
export interface LogEntryPayload {
|
|
||||||
level: number;
|
|
||||||
time: number; // epoch ms
|
|
||||||
msg: string;
|
|
||||||
name?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogBatchPayload {
|
|
||||||
logs: LogEntryPayload[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogQueryOptions {
|
|
||||||
deviceId: string;
|
|
||||||
level?: number; // minimum level
|
|
||||||
loggerName?: string;
|
|
||||||
since?: string; // ISO 8601
|
|
||||||
until?: string; // ISO 8601
|
|
||||||
search?: string; // substring search in msg
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogQueryResult {
|
|
||||||
logs: LogEntryPayload[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ILogIngestionPort {
|
|
||||||
/**
|
|
||||||
* Ingest a batch of log entries from a device.
|
|
||||||
*/
|
|
||||||
ingestBatch(deviceId: string, payload: LogBatchPayload): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query logs for a device with filtering.
|
|
||||||
*/
|
|
||||||
queryLogs(options: LogQueryOptions): Promise<LogQueryResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LOG_INGESTION_PORT = Symbol('LOG_INGESTION_PORT');
|
|
||||||
@ -13,13 +13,6 @@ interface ActiveSession {
|
|||||||
finalTranscription: string;
|
finalTranscription: string;
|
||||||
interimTranscription: string;
|
interimTranscription: string;
|
||||||
sttStream: ISTTStream | null;
|
sttStream: ISTTStream | null;
|
||||||
/**
|
|
||||||
* Audio chunks that arrived between session creation and the STT
|
|
||||||
* stream actually being open. Drained as soon as `sttStream` is
|
|
||||||
* assigned so we never lose the first few hundred milliseconds of
|
|
||||||
* speech.
|
|
||||||
*/
|
|
||||||
pendingChunks: Buffer[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `Tu es Ti-Pote, un petit robot de bureau animatronique, chaleureux et serviable.
|
const SYSTEM_PROMPT = `Tu es Ti-Pote, un petit robot de bureau animatronique, chaleureux et serviable.
|
||||||
@ -91,7 +84,6 @@ export class ConversationService implements IConversationPort {
|
|||||||
finalTranscription: '',
|
finalTranscription: '',
|
||||||
interimTranscription: '',
|
interimTranscription: '',
|
||||||
sttStream: null,
|
sttStream: null,
|
||||||
pendingChunks: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.activeSessions.set(deviceId, session);
|
this.activeSessions.set(deviceId, session);
|
||||||
@ -110,32 +102,15 @@ export class ConversationService implements IConversationPort {
|
|||||||
});
|
});
|
||||||
|
|
||||||
session.sttStream = sttStream;
|
session.sttStream = sttStream;
|
||||||
|
|
||||||
// Flush anything that arrived while Deepgram was spinning up.
|
|
||||||
if (session.pendingChunks.length > 0) {
|
|
||||||
this.logger.debug(
|
|
||||||
`Flushing ${session.pendingChunks.length} buffered chunks for ${deviceId}`,
|
|
||||||
);
|
|
||||||
for (const chunk of session.pendingChunks) {
|
|
||||||
sttStream.sendAudio(chunk);
|
|
||||||
}
|
|
||||||
session.pendingChunks = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
processAudioChunk(deviceId: string, chunk: Buffer, sampleRate: number): void {
|
processAudioChunk(deviceId: string, chunk: Buffer, sampleRate: number): void {
|
||||||
const session = this.activeSessions.get(deviceId);
|
const session = this.activeSessions.get(deviceId);
|
||||||
if (!session) {
|
if (!session?.sttStream) {
|
||||||
// No session at all → user sent audio without wake_word_detected.
|
this.logger.warn(`No active STT stream for device ${deviceId}, ignoring audio chunk`);
|
||||||
// Safe to ignore.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!session.sttStream) {
|
|
||||||
// Session exists but Deepgram is still opening. Buffer the chunk
|
|
||||||
// so it gets replayed as soon as the stream is ready.
|
|
||||||
session.pendingChunks.push(chunk);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session.sttStream.sendAudio(chunk);
|
session.sttStream.sendAudio(chunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { Device, DeviceStatus } from '../domain/entities/device.entity';
|
import { Device } from '../domain/entities/device.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeviceService {
|
export class DeviceService {
|
||||||
@ -37,11 +37,4 @@ export class DeviceService {
|
|||||||
async updateLastSeen(id: string): Promise<void> {
|
async updateLastSeen(id: string): Promise<void> {
|
||||||
await this.deviceRepository.update(id, { lastSeenAt: new Date() });
|
await this.deviceRepository.update(id, { lastSeenAt: new Date() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async setStatus(id: string, status: DeviceStatus): Promise<void> {
|
|
||||||
await this.deviceRepository.update(id, {
|
|
||||||
status,
|
|
||||||
lastSeenAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,137 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { HealthReport } from '../domain/entities/health-report.entity';
|
|
||||||
import {
|
|
||||||
IHealthTelemetryPort,
|
|
||||||
HealthReportPayload,
|
|
||||||
} from '../ports/inbound/health-telemetry.port';
|
|
||||||
|
|
||||||
// ── Alert thresholds ──
|
|
||||||
const THRESHOLDS = {
|
|
||||||
cpuTempCelsius: 80, // Raspberry Pi throttles at 85°C
|
|
||||||
memoryUsedPercent: 90,
|
|
||||||
diskUsedPercent: 90,
|
|
||||||
loadAvg1m: 3.0, // Pi Zero 2W has 4 cores
|
|
||||||
heapUsedPercent: 85,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class HealthTelemetryService implements IHealthTelemetryPort {
|
|
||||||
private readonly logger = new Logger(HealthTelemetryService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(HealthReport)
|
|
||||||
private readonly repo: Repository<HealthReport>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async ingestReport(deviceId: string, payload: HealthReportPayload): Promise<void> {
|
|
||||||
const report = this.repo.create({
|
|
||||||
deviceId,
|
|
||||||
cpuTempCelsius: payload.cpuTempCelsius,
|
|
||||||
memoryUsedMb: payload.memoryUsedMb,
|
|
||||||
memoryTotalMb: payload.memoryTotalMb,
|
|
||||||
diskUsedPercent: payload.diskUsedPercent,
|
|
||||||
loadAvg1m: payload.loadAvg1m,
|
|
||||||
heapUsedMb: payload.heapUsedMb,
|
|
||||||
heapTotalMb: payload.heapTotalMb,
|
|
||||||
uptimeSeconds: payload.uptimeSeconds,
|
|
||||||
wifiSsid: payload.wifiSsid,
|
|
||||||
wifiSignalDbm: payload.wifiSignalDbm,
|
|
||||||
clientVersion: payload.clientVersion,
|
|
||||||
nodeVersion: payload.nodeVersion,
|
|
||||||
reportedAt: new Date(payload.reportedAt),
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.repo.save(report);
|
|
||||||
|
|
||||||
// Log alerts inline for immediate visibility
|
|
||||||
const alerts = this.computeAlerts(payload);
|
|
||||||
if (alerts.length > 0) {
|
|
||||||
this.logger.warn({ deviceId, alerts }, 'Device health alerts');
|
|
||||||
} else {
|
|
||||||
this.logger.debug({ deviceId }, 'Health report ingested');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLatestReports(deviceId: string, limit = 20): Promise<HealthReportPayload[]> {
|
|
||||||
const reports = await this.repo.find({
|
|
||||||
where: { deviceId },
|
|
||||||
order: { createdAt: 'DESC' },
|
|
||||||
take: limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
return reports.map((r) => ({
|
|
||||||
cpuTempCelsius: r.cpuTempCelsius,
|
|
||||||
memoryUsedMb: r.memoryUsedMb,
|
|
||||||
memoryTotalMb: r.memoryTotalMb,
|
|
||||||
diskUsedPercent: r.diskUsedPercent,
|
|
||||||
loadAvg1m: r.loadAvg1m,
|
|
||||||
heapUsedMb: r.heapUsedMb,
|
|
||||||
heapTotalMb: r.heapTotalMb,
|
|
||||||
uptimeSeconds: r.uptimeSeconds,
|
|
||||||
wifiSsid: r.wifiSsid,
|
|
||||||
wifiSignalDbm: r.wifiSignalDbm,
|
|
||||||
clientVersion: r.clientVersion,
|
|
||||||
nodeVersion: r.nodeVersion,
|
|
||||||
reportedAt: r.reportedAt.toISOString(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkAlerts(deviceId: string): Promise<string[]> {
|
|
||||||
const latest = await this.repo.findOne({
|
|
||||||
where: { deviceId },
|
|
||||||
order: { createdAt: 'DESC' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!latest) return [];
|
|
||||||
|
|
||||||
return this.computeAlerts({
|
|
||||||
cpuTempCelsius: latest.cpuTempCelsius,
|
|
||||||
memoryUsedMb: latest.memoryUsedMb,
|
|
||||||
memoryTotalMb: latest.memoryTotalMb,
|
|
||||||
diskUsedPercent: latest.diskUsedPercent,
|
|
||||||
loadAvg1m: latest.loadAvg1m,
|
|
||||||
heapUsedMb: latest.heapUsedMb,
|
|
||||||
heapTotalMb: latest.heapTotalMb,
|
|
||||||
uptimeSeconds: latest.uptimeSeconds,
|
|
||||||
wifiSsid: latest.wifiSsid,
|
|
||||||
wifiSignalDbm: latest.wifiSignalDbm,
|
|
||||||
clientVersion: latest.clientVersion,
|
|
||||||
nodeVersion: latest.nodeVersion,
|
|
||||||
reportedAt: latest.reportedAt.toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private computeAlerts(payload: HealthReportPayload): string[] {
|
|
||||||
const alerts: string[] = [];
|
|
||||||
|
|
||||||
if (payload.cpuTempCelsius >= THRESHOLDS.cpuTempCelsius) {
|
|
||||||
alerts.push(`CPU temp critical: ${payload.cpuTempCelsius}°C (threshold: ${THRESHOLDS.cpuTempCelsius}°C)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const memPercent = (payload.memoryUsedMb / payload.memoryTotalMb) * 100;
|
|
||||||
if (memPercent >= THRESHOLDS.memoryUsedPercent) {
|
|
||||||
alerts.push(`Memory usage critical: ${memPercent.toFixed(0)}% (${payload.memoryUsedMb}/${payload.memoryTotalMb} MB)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.diskUsedPercent >= THRESHOLDS.diskUsedPercent) {
|
|
||||||
alerts.push(`Disk usage critical: ${payload.diskUsedPercent}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.loadAvg1m >= THRESHOLDS.loadAvg1m) {
|
|
||||||
alerts.push(`Load average high: ${payload.loadAvg1m}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const heapPercent = (payload.heapUsedMb / payload.heapTotalMb) * 100;
|
|
||||||
if (heapPercent >= THRESHOLDS.heapUsedPercent) {
|
|
||||||
alerts.push(`Heap usage critical: ${heapPercent.toFixed(0)}% (${payload.heapUsedMb}/${payload.heapTotalMb} MB)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.wifiSignalDbm !== null && payload.wifiSignalDbm < -80) {
|
|
||||||
alerts.push(`WiFi signal weak: ${payload.wifiSignalDbm} dBm`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository, MoreThanOrEqual, LessThanOrEqual, ILike } from 'typeorm';
|
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
|
||||||
import { DeviceLog } from '../domain/entities/device-log.entity';
|
|
||||||
import {
|
|
||||||
ILogIngestionPort,
|
|
||||||
LogBatchPayload,
|
|
||||||
LogEntryPayload,
|
|
||||||
LogQueryOptions,
|
|
||||||
LogQueryResult,
|
|
||||||
} from '../ports/inbound/log-ingestion.port';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class LogIngestionService implements ILogIngestionPort {
|
|
||||||
private readonly logger = new Logger(LogIngestionService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(DeviceLog)
|
|
||||||
private readonly repo: Repository<DeviceLog>,
|
|
||||||
private readonly events: EventEmitter2,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async ingestBatch(deviceId: string, payload: LogBatchPayload): Promise<void> {
|
|
||||||
if (!payload.logs || payload.logs.length === 0) return;
|
|
||||||
|
|
||||||
const entities = payload.logs.map((log) => {
|
|
||||||
// Extract known fields, put everything else in context
|
|
||||||
const { level, time, msg, name, ...rest } = log;
|
|
||||||
const context = Object.keys(rest).length > 0 ? rest : null;
|
|
||||||
|
|
||||||
return this.repo.create({
|
|
||||||
deviceId,
|
|
||||||
level: level ?? 30,
|
|
||||||
msg: msg ?? '',
|
|
||||||
loggerName: (name as string) ?? null,
|
|
||||||
context: context as Record<string, unknown> | null,
|
|
||||||
loggedAt: new Date(time ?? Date.now()),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.repo.save(entities);
|
|
||||||
|
|
||||||
this.logger.debug({ deviceId, count: entities.length }, 'Log batch ingested');
|
|
||||||
|
|
||||||
// Emit for SSE live stream
|
|
||||||
for (const entity of entities) {
|
|
||||||
this.events.emit('device.log', {
|
|
||||||
deviceId,
|
|
||||||
id: entity.id,
|
|
||||||
level: entity.level,
|
|
||||||
msg: entity.msg,
|
|
||||||
loggerName: entity.loggerName,
|
|
||||||
context: entity.context,
|
|
||||||
loggedAt: entity.loggedAt.toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async queryLogs(options: LogQueryOptions): Promise<LogQueryResult> {
|
|
||||||
const where: Record<string, unknown> = { deviceId: options.deviceId };
|
|
||||||
|
|
||||||
if (options.level !== undefined) {
|
|
||||||
where.level = MoreThanOrEqual(options.level);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.loggerName) {
|
|
||||||
where.loggerName = options.loggerName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.since) {
|
|
||||||
where.loggedAt = MoreThanOrEqual(new Date(options.since));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.until) {
|
|
||||||
// If we already set loggedAt for `since`, we need a raw query.
|
|
||||||
// For simplicity, we handle the common single-filter case here.
|
|
||||||
where.loggedAt = LessThanOrEqual(new Date(options.until));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.search) {
|
|
||||||
where.msg = ILike(`%${options.search}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [logs, total] = await this.repo.findAndCount({
|
|
||||||
where,
|
|
||||||
order: { loggedAt: 'DESC' },
|
|
||||||
take: options.limit ?? 50,
|
|
||||||
skip: options.offset ?? 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
logs: logs.map((l) => ({
|
|
||||||
level: l.level,
|
|
||||||
time: l.loggedAt.getTime(),
|
|
||||||
msg: l.msg,
|
|
||||||
name: l.loggerName ?? undefined,
|
|
||||||
...(l.context ?? {}),
|
|
||||||
})),
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
|
||||||
import { ICachePort, CACHE_PORT } from '../ports/outbound/cache.port';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
export interface PairingRequest {
|
|
||||||
requestId: string;
|
|
||||||
code: string;
|
|
||||||
deviceName: string;
|
|
||||||
status: 'pending' | 'confirmed';
|
|
||||||
deviceId?: string;
|
|
||||||
deviceToken?: string;
|
|
||||||
homeId?: string;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAIRING_TTL = 600; // 10 minutes
|
|
||||||
const PAIRING_CODE_PREFIX = 'pairing:code:';
|
|
||||||
const PAIRING_REQUEST_PREFIX = 'pairing:request:';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PairingService {
|
|
||||||
constructor(
|
|
||||||
@Inject(CACHE_PORT) private readonly cache: ICachePort,
|
|
||||||
private readonly authService: AuthService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 1: Robot requests pairing.
|
|
||||||
* Generates a 6-digit code and stores the request in Redis.
|
|
||||||
* Returns the requestId + code for the robot to display.
|
|
||||||
*/
|
|
||||||
async requestPairing(deviceName: string): Promise<{ requestId: string; code: string }> {
|
|
||||||
const requestId = crypto.randomUUID();
|
|
||||||
const code = this.generateCode();
|
|
||||||
|
|
||||||
const pairingRequest: PairingRequest = {
|
|
||||||
requestId,
|
|
||||||
code,
|
|
||||||
deviceName,
|
|
||||||
status: 'pending',
|
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store by requestId (for robot polling)
|
|
||||||
await this.cache.set<PairingRequest>(
|
|
||||||
`${PAIRING_REQUEST_PREFIX}${requestId}`,
|
|
||||||
pairingRequest,
|
|
||||||
PAIRING_TTL,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store code → requestId mapping (for user confirmation lookup)
|
|
||||||
await this.cache.set<string>(
|
|
||||||
`${PAIRING_CODE_PREFIX}${code}`,
|
|
||||||
requestId,
|
|
||||||
PAIRING_TTL,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { requestId, code };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 2: Robot polls for pairing status.
|
|
||||||
* Returns current status; once confirmed, includes deviceId + token.
|
|
||||||
*/
|
|
||||||
async getPairingStatus(requestId: string): Promise<PairingRequest> {
|
|
||||||
const request = await this.cache.get<PairingRequest>(
|
|
||||||
`${PAIRING_REQUEST_PREFIX}${requestId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
throw new NotFoundException('Pairing request not found or expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 3: User confirms pairing with the 6-digit code.
|
|
||||||
* Associates the device to the user's home, generates credentials.
|
|
||||||
*/
|
|
||||||
async confirmPairing(code: string, homeId: string): Promise<{ deviceId: string; deviceName: string }> {
|
|
||||||
// Look up requestId from code
|
|
||||||
const requestId = await this.cache.get<string>(
|
|
||||||
`${PAIRING_CODE_PREFIX}${code}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!requestId) {
|
|
||||||
throw new BadRequestException('Invalid or expired pairing code');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the pairing request
|
|
||||||
const request = await this.cache.get<PairingRequest>(
|
|
||||||
`${PAIRING_REQUEST_PREFIX}${requestId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!request) {
|
|
||||||
throw new BadRequestException('Pairing request expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.status === 'confirmed') {
|
|
||||||
throw new BadRequestException('Pairing code already used');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the device on the user's home
|
|
||||||
const { deviceId, token } = await this.authService.registerDevice(
|
|
||||||
homeId,
|
|
||||||
request.deviceName,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update the pairing request with credentials
|
|
||||||
const confirmedRequest: PairingRequest = {
|
|
||||||
...request,
|
|
||||||
status: 'confirmed',
|
|
||||||
deviceId,
|
|
||||||
deviceToken: token,
|
|
||||||
homeId,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.cache.set<PairingRequest>(
|
|
||||||
`${PAIRING_REQUEST_PREFIX}${requestId}`,
|
|
||||||
confirmedRequest,
|
|
||||||
PAIRING_TTL,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up the code mapping
|
|
||||||
await this.cache.del(`${PAIRING_CODE_PREFIX}${code}`);
|
|
||||||
|
|
||||||
return { deviceId, deviceName: request.deviceName };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a random 6-digit numeric code.
|
|
||||||
* Avoids ambiguous patterns (000000, 111111, etc.)
|
|
||||||
*/
|
|
||||||
private generateCode(): string {
|
|
||||||
let code: string;
|
|
||||||
do {
|
|
||||||
code = String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
|
||||||
} while (/^(\d)\1{5}$/.test(code)); // Avoid 000000, 111111, etc.
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class AddHealthReports1744540000000 implements MigrationInterface {
|
|
||||||
name = 'AddHealthReports1744540000000';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE "health_reports" (
|
|
||||||
"id" UUID DEFAULT uuid_generate_v4() NOT NULL,
|
|
||||||
"device_id" UUID NOT NULL,
|
|
||||||
"cpu_temp_celsius" REAL NOT NULL,
|
|
||||||
"memory_used_mb" REAL NOT NULL,
|
|
||||||
"memory_total_mb" REAL NOT NULL,
|
|
||||||
"disk_used_percent" REAL NOT NULL,
|
|
||||||
"load_avg_1m" REAL NOT NULL,
|
|
||||||
"heap_used_mb" REAL NOT NULL,
|
|
||||||
"heap_total_mb" REAL NOT NULL,
|
|
||||||
"uptime_seconds" INTEGER NOT NULL,
|
|
||||||
"wifi_ssid" VARCHAR(64),
|
|
||||||
"wifi_signal_dbm" SMALLINT,
|
|
||||||
"client_version" VARCHAR(20) NOT NULL,
|
|
||||||
"node_version" VARCHAR(30) NOT NULL,
|
|
||||||
"reported_at" TIMESTAMPTZ NOT NULL,
|
|
||||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
|
|
||||||
CONSTRAINT "PK_health_reports" PRIMARY KEY ("id"),
|
|
||||||
CONSTRAINT "FK_health_reports_device"
|
|
||||||
FOREIGN KEY ("device_id") REFERENCES "devices"("id") ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Query pattern: latest reports for a device, ordered by time
|
|
||||||
CREATE INDEX "IDX_health_reports_device_created"
|
|
||||||
ON "health_reports" ("device_id", "created_at" DESC);
|
|
||||||
|
|
||||||
-- Auto-cleanup: partition-friendly index for purging old rows
|
|
||||||
CREATE INDEX "IDX_health_reports_created"
|
|
||||||
ON "health_reports" ("created_at");
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "health_reports";`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class AddDeviceLogs1744540100000 implements MigrationInterface {
|
|
||||||
name = 'AddDeviceLogs1744540100000';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE "device_logs" (
|
|
||||||
"id" UUID DEFAULT uuid_generate_v4() NOT NULL,
|
|
||||||
"device_id" UUID NOT NULL,
|
|
||||||
"level" SMALLINT NOT NULL,
|
|
||||||
"msg" TEXT NOT NULL,
|
|
||||||
"logger_name" VARCHAR(64),
|
|
||||||
"context" JSONB,
|
|
||||||
"logged_at" TIMESTAMPTZ NOT NULL,
|
|
||||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
|
|
||||||
CONSTRAINT "PK_device_logs" PRIMARY KEY ("id"),
|
|
||||||
CONSTRAINT "FK_device_logs_device"
|
|
||||||
FOREIGN KEY ("device_id") REFERENCES "devices"("id") ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Primary query pattern: recent logs for a device
|
|
||||||
CREATE INDEX "IDX_device_logs_device_logged"
|
|
||||||
ON "device_logs" ("device_id", "logged_at" DESC);
|
|
||||||
|
|
||||||
-- Filter by level (e.g. show only errors)
|
|
||||||
CREATE INDEX "IDX_device_logs_device_level"
|
|
||||||
ON "device_logs" ("device_id", "level");
|
|
||||||
|
|
||||||
-- Purge old logs
|
|
||||||
CREATE INDEX "IDX_device_logs_logged"
|
|
||||||
ON "device_logs" ("logged_at");
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`DROP TABLE IF EXISTS "device_logs";`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
# Backend API base URL (no trailing slash, no /api suffix)
|
|
||||||
# Dev local: http://localhost:3000
|
|
||||||
# Prod VPS: https://api.ti-pote.example.com
|
|
||||||
VITE_API_URL=http://localhost:3000
|
|
||||||
39
apps/frontend/.gitignore
vendored
@ -1,39 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
dist/
|
|
||||||
dist-ssr/
|
|
||||||
build/
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
.vite/
|
|
||||||
|
|
||||||
# Env
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
|
|
||||||
# Tauri (Rust build artifacts)
|
|
||||||
src-tauri/target/
|
|
||||||
src-tauri/Cargo.lock
|
|
||||||
src-tauri/gen/schemas/
|
|
||||||
src-tauri/WixTools/
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
# @ti-pote/frontend
|
|
||||||
|
|
||||||
Desktop companion app for Ti-Pote. **Vite + React + TypeScript + Tailwind**,
|
|
||||||
wrappable as a native desktop app with **Tauri v2**.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Auth: register / login / auto refresh-token rotation
|
|
||||||
- Session persistence (Tauri Store plugin on desktop, localStorage in browser)
|
|
||||||
- Dashboard: list of associated robots (`GET /api/devices`)
|
|
||||||
- Robot pairing: 6-digit code screen wired to `POST /api/pairing/confirm`
|
|
||||||
|
|
||||||
## Quick start (web dev)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/frontend
|
|
||||||
cp .env.example .env # point VITE_API_URL to your backend
|
|
||||||
pnpm install
|
|
||||||
pnpm dev # http://localhost:1420
|
|
||||||
```
|
|
||||||
|
|
||||||
## Desktop build (Tauri v2)
|
|
||||||
|
|
||||||
Prerequisites: Rust toolchain (`rustup`) and the
|
|
||||||
[Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# First time only: generate the bundle icons from any source PNG
|
|
||||||
pnpm tauri icon path/to/logo.png
|
|
||||||
|
|
||||||
# Dev (hot reload + native window)
|
|
||||||
pnpm tauri dev
|
|
||||||
|
|
||||||
# Production bundle (.dmg / .app on macOS, .msi on Windows, .deb/.AppImage on Linux)
|
|
||||||
pnpm tauri build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project layout
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/frontend/
|
|
||||||
├── src/
|
|
||||||
│ ├── components/ Button / Input / Card / ProtectedRoute
|
|
||||||
│ ├── context/ AuthContext (React)
|
|
||||||
│ ├── lib/
|
|
||||||
│ │ ├── api.ts fetch wrapper + typed endpoints + auto-refresh
|
|
||||||
│ │ └── storage.ts Tauri Store ↔ localStorage fallback
|
|
||||||
│ ├── pages/ Login / Register / Dashboard / PairRobot
|
|
||||||
│ ├── styles/ Tailwind entry
|
|
||||||
│ ├── App.tsx Router
|
|
||||||
│ └── main.tsx Entry point
|
|
||||||
└── src-tauri/ Tauri v2 Rust wrapper
|
|
||||||
├── Cargo.toml
|
|
||||||
├── tauri.conf.json
|
|
||||||
├── capabilities/
|
|
||||||
└── src/{main.rs,lib.rs}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Backend API used
|
|
||||||
|
|
||||||
| Flow | Endpoint | Notes |
|
|
||||||
| -------- | ---------------------------- | ---------------------------------- |
|
|
||||||
| Register | `POST /api/auth/register` | Creates user + home |
|
|
||||||
| Login | `POST /api/auth/login` | |
|
|
||||||
| Refresh | `POST /api/auth/refresh` | Called transparently on 401 |
|
|
||||||
| Me | `GET /api/auth/me` | |
|
|
||||||
| Devices | `GET /api/devices` | Dashboard |
|
|
||||||
| Pair | `POST /api/pairing/confirm` | `{ code }` — 6-digit from robot UI |
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Ti-Pote</title>
|
|
||||||
</head>
|
|
||||||
<body class="bg-slate-950 text-slate-100 antialiased">
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@ti-pote/frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"lint": "eslint src --ext ts,tsx --max-warnings 0",
|
|
||||||
"tauri": "tauri"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tauri-apps/api": "^2.0.0",
|
|
||||||
"@tauri-apps/plugin-store": "^2.0.0",
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-router-dom": "^6.26.0",
|
|
||||||
"zustand": "^4.5.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
|
||||||
"@types/react": "^18.3.3",
|
|
||||||
"@types/react-dom": "^18.3.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"postcss": "^8.4.41",
|
|
||||||
"tailwindcss": "^3.4.10",
|
|
||||||
"typescript": "^5.5.4",
|
|
||||||
"vite": "^5.4.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
6
apps/frontend/src-tauri/.gitignore
vendored
@ -1,6 +0,0 @@
|
|||||||
# Generated by Cargo
|
|
||||||
target/
|
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
# Generated by Tauri
|
|
||||||
gen/schemas/
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "ti-pote-desktop"
|
|
||||||
version = "0.0.1"
|
|
||||||
description = "Ti-Pote desktop companion app"
|
|
||||||
authors = ["Arthur"]
|
|
||||||
edition = "2021"
|
|
||||||
rust-version = "1.77"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "ti_pote_desktop_lib"
|
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
tauri-build = { version = "2", features = [] }
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tauri = { version = "2", features = [] }
|
|
||||||
tauri-plugin-store = "2"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
# This feature is used for production builds or when a dev server is not specified,
|
|
||||||
# DO NOT REMOVE!!
|
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
tauri_build::build()
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
|
||||||
"identifier": "default",
|
|
||||||
"description": "Default permissions for the main window",
|
|
||||||
"windows": ["main"],
|
|
||||||
"permissions": [
|
|
||||||
"core:default",
|
|
||||||
"store:default"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#fff</color>
|
|
||||||
</resources>
|
|
||||||
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 816 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
@ -1,7 +0,0 @@
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
||||||
pub fn run() {
|
|
||||||
tauri::Builder::default()
|
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
|
||||||
.run(tauri::generate_context!())
|
|
||||||
.expect("error while running tauri application");
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
// Prevents additional console window on Windows in release
|
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
ti_pote_desktop_lib::run()
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
|
||||||
"productName": "Ti-Pote",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"identifier": "com.tipote.desktop",
|
|
||||||
"build": {
|
|
||||||
"beforeDevCommand": "pnpm dev",
|
|
||||||
"devUrl": "http://localhost:1420",
|
|
||||||
"beforeBuildCommand": "pnpm build",
|
|
||||||
"frontendDist": "../dist"
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"windows": [
|
|
||||||
{
|
|
||||||
"title": "Ti-Pote",
|
|
||||||
"width": 1100,
|
|
||||||
"height": 760,
|
|
||||||
"minWidth": 900,
|
|
||||||
"minHeight": 600,
|
|
||||||
"resizable": true,
|
|
||||||
"fullscreen": false
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"security": {
|
|
||||||
"csp": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"bundle": {
|
|
||||||
"active": true,
|
|
||||||
"targets": "all",
|
|
||||||
"icon": [
|
|
||||||
"icons/32x32.png",
|
|
||||||
"icons/128x128.png",
|
|
||||||
"icons/128x128@2x.png",
|
|
||||||
"icons/icon.icns",
|
|
||||||
"icons/icon.ico"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"plugins": {}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
|
||||||
import { useAuth } from './context/AuthContext';
|
|
||||||
import { DashboardPage } from './pages/DashboardPage';
|
|
||||||
import { LoginPage } from './pages/LoginPage';
|
|
||||||
import { PairRobotPage } from './pages/PairRobotPage';
|
|
||||||
import { RegisterPage } from './pages/RegisterPage';
|
|
||||||
import { SetupRobotPage } from './pages/SetupRobotPage';
|
|
||||||
import { DeviceDetailPage } from './pages/DeviceDetailPage';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router.
|
|
||||||
*
|
|
||||||
* - Public routes (login/register) auto-redirect to "/" when the user
|
|
||||||
* is already authenticated — avoids the annoying loop where a logged-in
|
|
||||||
* user clicks back and lands on a login form.
|
|
||||||
* - Protected routes are gated by <ProtectedRoute>.
|
|
||||||
*/
|
|
||||||
export function App() {
|
|
||||||
const { status } = useAuth();
|
|
||||||
const authed = status === 'authenticated';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route
|
|
||||||
path="/login"
|
|
||||||
element={authed ? <Navigate to="/" replace /> : <LoginPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/register"
|
|
||||||
element={authed ? <Navigate to="/" replace /> : <RegisterPage />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<DashboardPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/pair"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<PairRobotPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/setup"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<SetupRobotPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/devices/:deviceId"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<DeviceDetailPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gate a subtree behind an authenticated user.
|
|
||||||
*
|
|
||||||
* - While the auth state is still bootstrapping (refreshing the session
|
|
||||||
* from storage), render a neutral splash.
|
|
||||||
* - If the user is unauthenticated, redirect to /login while keeping
|
|
||||||
* the original destination in location state so we can bounce back
|
|
||||||
* after login.
|
|
||||||
*/
|
|
||||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
|
||||||
const { status } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (status === 'loading') {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full items-center justify-center">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-400 border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'unauthenticated') {
|
|
||||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react';
|
|
||||||
|
|
||||||
// ─── Button ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
|
||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
||||||
variant?: Variant;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VARIANT_CLASSES: Record<Variant, string> = {
|
|
||||||
primary:
|
|
||||||
'bg-brand-500 hover:bg-brand-400 active:bg-brand-600 text-white shadow-lg shadow-brand-500/30',
|
|
||||||
secondary: 'bg-slate-800 hover:bg-slate-700 text-slate-100 border border-slate-700',
|
|
||||||
ghost: 'bg-transparent hover:bg-slate-800/60 text-slate-300',
|
|
||||||
danger: 'bg-red-600 hover:bg-red-500 text-white',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Button({
|
|
||||||
variant = 'primary',
|
|
||||||
loading = false,
|
|
||||||
disabled,
|
|
||||||
className = '',
|
|
||||||
children,
|
|
||||||
...rest
|
|
||||||
}: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...rest}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
className={[
|
|
||||||
'inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5',
|
|
||||||
'text-sm font-medium transition-all duration-150',
|
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-brand-400/50 focus:ring-offset-2 focus:ring-offset-slate-950',
|
|
||||||
VARIANT_CLASSES[variant],
|
|
||||||
className,
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{loading && (
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 animate-spin"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="3"
|
|
||||||
className="opacity-25"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
className="opacity-75"
|
|
||||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Input ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
||||||
label?: string;
|
|
||||||
error?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Input({ label, error, className = '', id, ...rest }: InputProps) {
|
|
||||||
const inputId = id || rest.name;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{label && (
|
|
||||||
<label htmlFor={inputId} className="text-xs font-medium text-slate-400">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
id={inputId}
|
|
||||||
{...rest}
|
|
||||||
className={[
|
|
||||||
'rounded-xl border bg-slate-900/60 px-3.5 py-2.5 text-sm text-slate-100',
|
|
||||||
'placeholder:text-slate-500',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-brand-400/40',
|
|
||||||
'transition-colors',
|
|
||||||
error
|
|
||||||
? 'border-red-500/60 focus:border-red-400'
|
|
||||||
: 'border-slate-700 focus:border-brand-400/60',
|
|
||||||
className,
|
|
||||||
].join(' ')}
|
|
||||||
/>
|
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Card ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function Card({
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'rounded-2xl border border-slate-800 bg-slate-900/50 backdrop-blur-xl',
|
|
||||||
'shadow-2xl shadow-black/40',
|
|
||||||
className,
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── StatusBadge ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: 'online' | 'offline' | 'updating' }) {
|
|
||||||
const styles = {
|
|
||||||
online: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30',
|
|
||||||
offline: 'bg-slate-500/10 text-slate-400 border-slate-500/30',
|
|
||||||
updating: 'bg-amber-500/10 text-amber-400 border-amber-500/30',
|
|
||||||
}[status];
|
|
||||||
|
|
||||||
const dot = {
|
|
||||||
online: 'bg-emerald-400 shadow-emerald-400/60',
|
|
||||||
offline: 'bg-slate-500',
|
|
||||||
updating: 'bg-amber-400 shadow-amber-400/60 animate-pulse',
|
|
||||||
}[status];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${styles}`}
|
|
||||||
>
|
|
||||||
<span className={`h-1.5 w-1.5 rounded-full shadow-[0_0_8px] ${dot}`} />
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
import {
|
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from 'react';
|
|
||||||
import {
|
|
||||||
api,
|
|
||||||
hasStoredSession,
|
|
||||||
type LoginInput,
|
|
||||||
type Me,
|
|
||||||
type RegisterInput,
|
|
||||||
} from '../lib/api';
|
|
||||||
|
|
||||||
type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
|
||||||
|
|
||||||
interface AuthContextValue {
|
|
||||||
status: AuthStatus;
|
|
||||||
user: Me | null;
|
|
||||||
login: (input: LoginInput) => Promise<void>;
|
|
||||||
register: (input: RegisterInput) => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
refreshMe: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [status, setStatus] = useState<AuthStatus>('loading');
|
|
||||||
const [user, setUser] = useState<Me | null>(null);
|
|
||||||
|
|
||||||
// Bootstrap: if we have a refresh token on disk, try /auth/me
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
if (!(await hasStoredSession())) {
|
|
||||||
if (!cancelled) setStatus('unauthenticated');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const me = await api.me();
|
|
||||||
if (cancelled) return;
|
|
||||||
setUser(me);
|
|
||||||
setStatus('authenticated');
|
|
||||||
} catch {
|
|
||||||
if (cancelled) return;
|
|
||||||
setUser(null);
|
|
||||||
setStatus('unauthenticated');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback(async (input: LoginInput) => {
|
|
||||||
await api.login(input);
|
|
||||||
const me = await api.me();
|
|
||||||
setUser(me);
|
|
||||||
setStatus('authenticated');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const register = useCallback(async (input: RegisterInput) => {
|
|
||||||
await api.register(input);
|
|
||||||
const me = await api.me();
|
|
||||||
setUser(me);
|
|
||||||
setStatus('authenticated');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
await api.logout();
|
|
||||||
setUser(null);
|
|
||||||
setStatus('unauthenticated');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshMe = useCallback(async () => {
|
|
||||||
const me = await api.me();
|
|
||||||
setUser(me);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo<AuthContextValue>(
|
|
||||||
() => ({ status, user, login, register, logout, refreshMe }),
|
|
||||||
[status, user, login, register, logout, refreshMe],
|
|
||||||
);
|
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth(): AuthContextValue {
|
|
||||||
const ctx = useContext(AuthContext);
|
|
||||||
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@ -1,308 +0,0 @@
|
|||||||
/**
|
|
||||||
* Thin typed wrapper around fetch with:
|
|
||||||
* - base URL from VITE_API_URL (default http://localhost:3000)
|
|
||||||
* - automatic `Authorization: Bearer <access>` injection
|
|
||||||
* - transparent refresh-token rotation on 401
|
|
||||||
* - typed error class
|
|
||||||
*
|
|
||||||
* Token persistence is delegated to `lib/storage.ts` (Tauri store in
|
|
||||||
* desktop builds, localStorage in pure browser dev).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { storage } from './storage';
|
|
||||||
|
|
||||||
const BASE_URL =
|
|
||||||
(import.meta.env.VITE_API_URL as string | undefined)?.replace(/\/$/, '') ||
|
|
||||||
'http://localhost:3000';
|
|
||||||
|
|
||||||
const API_PREFIX = '/api';
|
|
||||||
|
|
||||||
const ACCESS_KEY = 'auth.accessToken';
|
|
||||||
const REFRESH_KEY = 'auth.refreshToken';
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface Tokens {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Me {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
homeId: string;
|
|
||||||
type: 'user';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterInput {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
displayName: string;
|
|
||||||
homeName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginInput {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceSummary {
|
|
||||||
id: string;
|
|
||||||
homeId: string;
|
|
||||||
name: string;
|
|
||||||
status: 'online' | 'offline' | 'updating';
|
|
||||||
firmwareVersion: string | null;
|
|
||||||
lastSeenAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PairingConfirmResult {
|
|
||||||
deviceId: string;
|
|
||||||
deviceName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HealthReport {
|
|
||||||
cpuTempCelsius: number;
|
|
||||||
memoryUsedMb: number;
|
|
||||||
memoryTotalMb: number;
|
|
||||||
diskUsedPercent: number;
|
|
||||||
loadAvg1m: number;
|
|
||||||
heapUsedMb: number;
|
|
||||||
heapTotalMb: number;
|
|
||||||
uptimeSeconds: number;
|
|
||||||
wifiSsid: string | null;
|
|
||||||
wifiSignalDbm: number | null;
|
|
||||||
clientVersion: string;
|
|
||||||
nodeVersion: string;
|
|
||||||
reportedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HealthReportsResponse {
|
|
||||||
deviceId: string;
|
|
||||||
reports: HealthReport[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HealthAlertsResponse {
|
|
||||||
deviceId: string;
|
|
||||||
alerts: string[];
|
|
||||||
healthy: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogEntry {
|
|
||||||
level: number;
|
|
||||||
levelLabel: string;
|
|
||||||
time: number;
|
|
||||||
msg: string;
|
|
||||||
name?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogsResponse {
|
|
||||||
deviceId: string;
|
|
||||||
logs: LogEntry[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogQueryParams {
|
|
||||||
level?: number;
|
|
||||||
logger?: string;
|
|
||||||
since?: string;
|
|
||||||
until?: string;
|
|
||||||
search?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Error ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export class ApiError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly status: number,
|
|
||||||
message: string,
|
|
||||||
public readonly body?: unknown,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'ApiError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Token helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function getAccessToken(): Promise<string | null> {
|
|
||||||
return storage.get<string>(ACCESS_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRefreshToken(): Promise<string | null> {
|
|
||||||
return storage.get<string>(REFRESH_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveTokens(tokens: Tokens): Promise<void> {
|
|
||||||
await storage.set(ACCESS_KEY, tokens.accessToken);
|
|
||||||
await storage.set(REFRESH_KEY, tokens.refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearTokens(): Promise<void> {
|
|
||||||
await storage.delete(ACCESS_KEY);
|
|
||||||
await storage.delete(REFRESH_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hasStoredSession(): Promise<boolean> {
|
|
||||||
const [a, r] = await Promise.all([getAccessToken(), getRefreshToken()]);
|
|
||||||
return Boolean(a && r);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Core fetch with refresh-on-401 ─────────────────────────────────
|
|
||||||
|
|
||||||
interface RequestOptions {
|
|
||||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
||||||
body?: unknown;
|
|
||||||
auth?: boolean; // default true
|
|
||||||
// Internal: prevents infinite refresh loops
|
|
||||||
_retried?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|
||||||
const { method = 'GET', body, auth = true, _retried = false } = opts;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (auth) {
|
|
||||||
const token = await getAccessToken();
|
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}${API_PREFIX}${path}`, {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attempt transparent refresh on 401 (once)
|
|
||||||
if (res.status === 401 && auth && !_retried) {
|
|
||||||
const refreshed = await tryRefresh();
|
|
||||||
if (refreshed) {
|
|
||||||
return request<T>(path, { ...opts, _retried: true });
|
|
||||||
}
|
|
||||||
await clearTokens();
|
|
||||||
}
|
|
||||||
|
|
||||||
const isJson = res.headers.get('content-type')?.includes('application/json');
|
|
||||||
const payload: unknown = isJson ? await res.json().catch(() => null) : null;
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const message =
|
|
||||||
(payload &&
|
|
||||||
typeof payload === 'object' &&
|
|
||||||
'message' in payload &&
|
|
||||||
String((payload as { message: unknown }).message)) ||
|
|
||||||
res.statusText ||
|
|
||||||
`HTTP ${res.status}`;
|
|
||||||
throw new ApiError(res.status, message, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
let refreshInFlight: Promise<boolean> | null = null;
|
|
||||||
|
|
||||||
async function tryRefresh(): Promise<boolean> {
|
|
||||||
if (refreshInFlight) return refreshInFlight;
|
|
||||||
|
|
||||||
refreshInFlight = (async () => {
|
|
||||||
const refreshToken = await getRefreshToken();
|
|
||||||
if (!refreshToken) return false;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${BASE_URL}${API_PREFIX}/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ refreshToken }),
|
|
||||||
});
|
|
||||||
if (!res.ok) return false;
|
|
||||||
const tokens = (await res.json()) as Tokens;
|
|
||||||
await saveTokens(tokens);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
// Release slot after microtask so concurrent callers can await
|
|
||||||
setTimeout(() => {
|
|
||||||
refreshInFlight = null;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return refreshInFlight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Typed endpoint wrappers ────────────────────────────────────────
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
// Auth
|
|
||||||
async register(input: RegisterInput): Promise<Tokens> {
|
|
||||||
const tokens = await request<Tokens>('/auth/register', {
|
|
||||||
method: 'POST',
|
|
||||||
body: input,
|
|
||||||
auth: false,
|
|
||||||
});
|
|
||||||
await saveTokens(tokens);
|
|
||||||
return tokens;
|
|
||||||
},
|
|
||||||
|
|
||||||
async login(input: LoginInput): Promise<Tokens> {
|
|
||||||
const tokens = await request<Tokens>('/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: input,
|
|
||||||
auth: false,
|
|
||||||
});
|
|
||||||
await saveTokens(tokens);
|
|
||||||
return tokens;
|
|
||||||
},
|
|
||||||
|
|
||||||
async me(): Promise<Me> {
|
|
||||||
return request<Me>('/auth/me');
|
|
||||||
},
|
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
|
||||||
await clearTokens();
|
|
||||||
},
|
|
||||||
|
|
||||||
// Devices
|
|
||||||
async listDevices(): Promise<DeviceSummary[]> {
|
|
||||||
return request<DeviceSummary[]>('/devices');
|
|
||||||
},
|
|
||||||
|
|
||||||
// Pairing
|
|
||||||
async confirmPairing(code: string): Promise<PairingConfirmResult> {
|
|
||||||
return request<PairingConfirmResult>('/pairing/confirm', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { code },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Health
|
|
||||||
async getHealthReports(deviceId: string, limit = 20): Promise<HealthReportsResponse> {
|
|
||||||
return request<HealthReportsResponse>(`/devices/${deviceId}/health/reports?limit=${limit}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async getHealthAlerts(deviceId: string): Promise<HealthAlertsResponse> {
|
|
||||||
return request<HealthAlertsResponse>(`/devices/${deviceId}/health/alerts`);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Logs
|
|
||||||
async getDeviceLogs(deviceId: string, params: LogQueryParams = {}): Promise<LogsResponse> {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
if (params.level !== undefined) searchParams.set('level', String(params.level));
|
|
||||||
if (params.logger) searchParams.set('logger', params.logger);
|
|
||||||
if (params.since) searchParams.set('since', params.since);
|
|
||||||
if (params.until) searchParams.set('until', params.until);
|
|
||||||
if (params.search) searchParams.set('search', params.search);
|
|
||||||
if (params.limit !== undefined) searchParams.set('limit', String(params.limit));
|
|
||||||
if (params.offset !== undefined) searchParams.set('offset', String(params.offset));
|
|
||||||
const qs = searchParams.toString();
|
|
||||||
return request<LogsResponse>(`/devices/${deviceId}/logs${qs ? `?${qs}` : ''}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* Client for the Ti-Pote robot's local HTTP API (captive portal).
|
|
||||||
*
|
|
||||||
* When the robot boots without WiFi, it creates an AP (Access Point)
|
|
||||||
* named "Ti-Pote" at 192.168.4.1:80. The desktop app connects to
|
|
||||||
* this AP and uses these endpoints to configure WiFi.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DEFAULT_ROBOT_URL = 'http://192.168.4.1';
|
|
||||||
|
|
||||||
export interface RobotStatus {
|
|
||||||
ready: boolean;
|
|
||||||
robotName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WifiStatus {
|
|
||||||
connected: boolean;
|
|
||||||
ssid: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WifiNetwork {
|
|
||||||
ssid: string;
|
|
||||||
signal: number;
|
|
||||||
security: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WifiConnectResult {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a robot local API client.
|
|
||||||
* @param baseUrl Override the robot's AP address (useful for dev/testing)
|
|
||||||
*/
|
|
||||||
export function createRobotLocalApi(baseUrl = DEFAULT_ROBOT_URL) {
|
|
||||||
const url = baseUrl.replace(/\/$/, '');
|
|
||||||
|
|
||||||
async function get<T>(path: string): Promise<T> {
|
|
||||||
const res = await fetch(`${url}${path}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Robot API error: ${res.status} ${res.statusText}`);
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function post<T>(path: string, body: unknown): Promise<T> {
|
|
||||||
const res = await fetch(`${url}${path}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Robot API error: ${res.status} ${res.statusText}`);
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* Check if the robot is reachable and get its name.
|
|
||||||
*/
|
|
||||||
async getStatus(): Promise<RobotStatus> {
|
|
||||||
return get<RobotStatus>('/api/status');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the robot is connected to WiFi.
|
|
||||||
*/
|
|
||||||
async getWifiStatus(): Promise<WifiStatus> {
|
|
||||||
return get<WifiStatus>('/api/wifi/status');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan for available WiFi networks.
|
|
||||||
*/
|
|
||||||
async scanWifi(): Promise<WifiNetwork[]> {
|
|
||||||
return get<WifiNetwork[]>('/api/wifi/scan');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to connect the robot to a WiFi network.
|
|
||||||
* WARNING: After calling this, the robot will disconnect from AP mode.
|
|
||||||
* The app will lose connection to the robot.
|
|
||||||
*/
|
|
||||||
async connectWifi(ssid: string, password: string): Promise<WifiConnectResult> {
|
|
||||||
return post<WifiConnectResult>('/api/wifi/connect', { ssid, password });
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to detect if we're connected to the robot's AP.
|
|
||||||
* Returns true if the robot's local API is reachable.
|
|
||||||
*/
|
|
||||||
async isReachable(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
||||||
const res = await fetch(`${url}/api/status`, {
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
clearTimeout(timeout);
|
|
||||||
return res.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RobotLocalApi = ReturnType<typeof createRobotLocalApi>;
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* Persistent key-value storage.
|
|
||||||
*
|
|
||||||
* In a Tauri desktop build we prefer the `@tauri-apps/plugin-store` plugin
|
|
||||||
* (encrypted, per-app location). In a plain browser dev build we fall back
|
|
||||||
* to `localStorage`. The same async API is exposed in both cases so callers
|
|
||||||
* never care which backend they are talking to.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type StorageBackend = {
|
|
||||||
get<T = unknown>(key: string): Promise<T | null>;
|
|
||||||
set(key: string, value: unknown): Promise<void>;
|
|
||||||
delete(key: string): Promise<void>;
|
|
||||||
clear(): Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
let backendPromise: Promise<StorageBackend> | null = null;
|
|
||||||
|
|
||||||
function isTauri(): boolean {
|
|
||||||
return (
|
|
||||||
typeof window !== 'undefined' &&
|
|
||||||
// Tauri v2 sets these globals on window at runtime
|
|
||||||
('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createBackend(): Promise<StorageBackend> {
|
|
||||||
if (isTauri()) {
|
|
||||||
try {
|
|
||||||
const { Store } = await import('@tauri-apps/plugin-store');
|
|
||||||
const store = await Store.load('ti-pote.json');
|
|
||||||
return {
|
|
||||||
async get<T>(key: string) {
|
|
||||||
const value = await store.get<T>(key);
|
|
||||||
return value ?? null;
|
|
||||||
},
|
|
||||||
async set(key, value) {
|
|
||||||
await store.set(key, value);
|
|
||||||
await store.save();
|
|
||||||
},
|
|
||||||
async delete(key) {
|
|
||||||
await store.delete(key);
|
|
||||||
await store.save();
|
|
||||||
},
|
|
||||||
async clear() {
|
|
||||||
await store.clear();
|
|
||||||
await store.save();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[storage] Tauri store unavailable, falling back to localStorage', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser fallback
|
|
||||||
return {
|
|
||||||
async get<T>(key: string) {
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
if (raw == null) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as T;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async set(key, value) {
|
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
|
||||||
},
|
|
||||||
async delete(key) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
},
|
|
||||||
async clear() {
|
|
||||||
localStorage.clear();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBackend(): Promise<StorageBackend> {
|
|
||||||
if (!backendPromise) backendPromise = createBackend();
|
|
||||||
return backendPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const storage = {
|
|
||||||
async get<T = unknown>(key: string): Promise<T | null> {
|
|
||||||
const b = await getBackend();
|
|
||||||
return b.get<T>(key);
|
|
||||||
},
|
|
||||||
async set(key: string, value: unknown): Promise<void> {
|
|
||||||
const b = await getBackend();
|
|
||||||
return b.set(key, value);
|
|
||||||
},
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
const b = await getBackend();
|
|
||||||
return b.delete(key);
|
|
||||||
},
|
|
||||||
async clear(): Promise<void> {
|
|
||||||
const b = await getBackend();
|
|
||||||
return b.clear();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
|
||||||
import { App } from './App';
|
|
||||||
import { AuthProvider } from './context/AuthContext';
|
|
||||||
import './styles/index.css';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<BrowserRouter>
|
|
||||||
<AuthProvider>
|
|
||||||
<App />
|
|
||||||
</AuthProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Button, Card, StatusBadge } from '../components/ui';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
import { api, ApiError, type DeviceSummary } from '../lib/api';
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const [devices, setDevices] = useState<DeviceSummary[] | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
async function fetchDevices() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const list = await api.listDevices();
|
|
||||||
setDevices(list);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError ? err.message : 'Erreur réseau');
|
|
||||||
setDevices([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void fetchDevices();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-5xl flex-col gap-6 p-8">
|
|
||||||
{/* ─ Header ─ */}
|
|
||||||
<header className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-wider text-slate-500">Tableau de bord</p>
|
|
||||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight">
|
|
||||||
Bonjour{user ? `, ${user.email.split('@')[0]}` : ''} 👋
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" onClick={() => void logout()}>
|
|
||||||
Déconnexion
|
|
||||||
</Button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ─ Actions ─ */}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<h2 className="text-lg font-medium">Tes robots</h2>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="secondary" onClick={() => void fetchDevices()}>
|
|
||||||
Rafraîchir
|
|
||||||
</Button>
|
|
||||||
<Link to="/setup">
|
|
||||||
<Button>+ Nouveau robot</Button>
|
|
||||||
</Link>
|
|
||||||
<Link to="/pair">
|
|
||||||
<Button variant="secondary">Code uniquement</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ─ Device list ─ */}
|
|
||||||
{loading && (
|
|
||||||
<Card className="p-8 text-center text-sm text-slate-400">Chargement…</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && error && (
|
|
||||||
<Card className="border-red-500/30 bg-red-500/5 p-6 text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && devices && devices.length === 0 && (
|
|
||||||
<Card className="flex flex-col items-center gap-4 p-12 text-center">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-slate-800 text-4xl">
|
|
||||||
🤖
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Aucun robot associé</h3>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Allume ton Ti-Pote puis associe-le avec le code qui s'affichera.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link to="/setup">
|
|
||||||
<Button>Configurer un robot</Button>
|
|
||||||
</Link>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && !error && devices && devices.length > 0 && (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{devices.map((d) => (
|
|
||||||
<Link key={d.id} to={`/devices/${d.id}`} className="group">
|
|
||||||
<Card className="p-5 transition-colors group-hover:border-slate-700">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="truncate text-base font-medium group-hover:text-brand-400 transition-colors">
|
|
||||||
{d.name}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-0.5 truncate font-mono text-xs text-slate-500">
|
|
||||||
{d.id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={d.status} />
|
|
||||||
</div>
|
|
||||||
<dl className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
|
|
||||||
<div>
|
|
||||||
<dt className="inline text-slate-500">Firmware : </dt>
|
|
||||||
<dd className="inline">{d.firmwareVersion || '—'}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="inline text-slate-500">Vu pour la dernière fois : </dt>
|
|
||||||
<dd className="inline">
|
|
||||||
{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'jamais'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,441 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { Link, useParams } from 'react-router-dom';
|
|
||||||
import { Button, Card, StatusBadge } from '../components/ui';
|
|
||||||
import {
|
|
||||||
api,
|
|
||||||
ApiError,
|
|
||||||
type DeviceSummary,
|
|
||||||
type HealthReport,
|
|
||||||
type LogEntry,
|
|
||||||
} from '../lib/api';
|
|
||||||
|
|
||||||
// ─── Level helpers ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const LEVEL_COLORS: Record<string, string> = {
|
|
||||||
trace: 'text-slate-500',
|
|
||||||
debug: 'text-slate-400',
|
|
||||||
info: 'text-blue-400',
|
|
||||||
warn: 'text-amber-400',
|
|
||||||
error: 'text-red-400',
|
|
||||||
fatal: 'text-red-500 font-bold',
|
|
||||||
};
|
|
||||||
|
|
||||||
const LEVEL_OPTIONS = [
|
|
||||||
{ value: 0, label: 'Tous' },
|
|
||||||
{ value: 20, label: 'Debug+' },
|
|
||||||
{ value: 30, label: 'Info+' },
|
|
||||||
{ value: 40, label: 'Warn+' },
|
|
||||||
{ value: 50, label: 'Error+' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Component ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function DeviceDetailPage() {
|
|
||||||
const { deviceId } = useParams<{ deviceId: string }>();
|
|
||||||
const [tab, setTab] = useState<'health' | 'logs'>('health');
|
|
||||||
|
|
||||||
// Device info
|
|
||||||
const [device, setDevice] = useState<DeviceSummary | null>(null);
|
|
||||||
|
|
||||||
// Health
|
|
||||||
const [reports, setReports] = useState<HealthReport[]>([]);
|
|
||||||
const [alerts, setAlerts] = useState<string[]>([]);
|
|
||||||
const [healthLoading, setHealthLoading] = useState(true);
|
|
||||||
|
|
||||||
// Logs
|
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
||||||
const [logsTotal, setLogsTotal] = useState(0);
|
|
||||||
const [logsLoading, setLogsLoading] = useState(false);
|
|
||||||
const [logLevel, setLogLevel] = useState(0);
|
|
||||||
const [logSearch, setLogSearch] = useState('');
|
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ── Fetch device info ──
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!deviceId) return;
|
|
||||||
api
|
|
||||||
.listDevices()
|
|
||||||
.then((devices) => {
|
|
||||||
const d = devices.find((x) => x.id === deviceId);
|
|
||||||
if (d) setDevice(d);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [deviceId]);
|
|
||||||
|
|
||||||
// ── Fetch health data ──
|
|
||||||
|
|
||||||
const fetchHealth = useCallback(async () => {
|
|
||||||
if (!deviceId) return;
|
|
||||||
setHealthLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const [reportsRes, alertsRes] = await Promise.all([
|
|
||||||
api.getHealthReports(deviceId, 20),
|
|
||||||
api.getHealthAlerts(deviceId),
|
|
||||||
]);
|
|
||||||
setReports(reportsRes.reports);
|
|
||||||
setAlerts(alertsRes.alerts);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError ? err.message : 'Erreur réseau');
|
|
||||||
} finally {
|
|
||||||
setHealthLoading(false);
|
|
||||||
}
|
|
||||||
}, [deviceId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tab === 'health') fetchHealth();
|
|
||||||
}, [tab, fetchHealth]);
|
|
||||||
|
|
||||||
// ── Fetch logs ──
|
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
|
||||||
if (!deviceId) return;
|
|
||||||
setLogsLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await api.getDeviceLogs(deviceId, {
|
|
||||||
level: logLevel || undefined,
|
|
||||||
search: logSearch || undefined,
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
setLogs(res.logs);
|
|
||||||
setLogsTotal(res.total);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof ApiError ? err.message : 'Erreur réseau');
|
|
||||||
} finally {
|
|
||||||
setLogsLoading(false);
|
|
||||||
}
|
|
||||||
}, [deviceId, logLevel, logSearch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tab === 'logs') fetchLogs();
|
|
||||||
}, [tab, fetchLogs]);
|
|
||||||
|
|
||||||
// ── Helpers ──
|
|
||||||
|
|
||||||
function formatUptime(seconds: number): string {
|
|
||||||
const h = Math.floor(seconds / 3600);
|
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
|
||||||
if (h > 24) {
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
return `${d}j ${h % 24}h`;
|
|
||||||
}
|
|
||||||
return `${h}h ${m}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleString('fr-FR', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const latest = reports[0] ?? null;
|
|
||||||
|
|
||||||
// ─── Render ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-5xl flex-col gap-6 p-8">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-slate-800 text-slate-400 hover:bg-slate-800/60"
|
|
||||||
>
|
|
||||||
←
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold tracking-tight">
|
|
||||||
{device?.name ?? 'Robot'}
|
|
||||||
</h1>
|
|
||||||
<p className="font-mono text-xs text-slate-500">{deviceId}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{device && <StatusBadge status={device.status} />}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-1 rounded-xl bg-slate-900/50 p-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setTab('health')}
|
|
||||||
className={[
|
|
||||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
|
||||||
tab === 'health'
|
|
||||||
? 'bg-slate-800 text-slate-100 shadow'
|
|
||||||
: 'text-slate-400 hover:text-slate-200',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
Santé
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTab('logs')}
|
|
||||||
className={[
|
|
||||||
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
|
||||||
tab === 'logs'
|
|
||||||
? 'bg-slate-800 text-slate-100 shadow'
|
|
||||||
: 'text-slate-400 hover:text-slate-200',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
Logs
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Card className="border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Health tab ── */}
|
|
||||||
{tab === 'health' && (
|
|
||||||
<>
|
|
||||||
{/* Alerts */}
|
|
||||||
{alerts.length > 0 && (
|
|
||||||
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
|
|
||||||
<h3 className="mb-2 text-sm font-medium text-amber-400">⚠ Alertes</h3>
|
|
||||||
<ul className="flex flex-col gap-1">
|
|
||||||
{alerts.map((a, i) => (
|
|
||||||
<li key={i} className="text-sm text-amber-300/80">
|
|
||||||
• {a}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Live metrics */}
|
|
||||||
{healthLoading && !latest && (
|
|
||||||
<Card className="p-8 text-center text-sm text-slate-400">Chargement...</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{latest && (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<MetricCard
|
|
||||||
label="CPU"
|
|
||||||
value={`${latest.cpuTempCelsius}°C`}
|
|
||||||
warn={latest.cpuTempCelsius >= 70}
|
|
||||||
critical={latest.cpuTempCelsius >= 80}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="RAM"
|
|
||||||
value={`${latest.memoryUsedMb.toFixed(0)} / ${latest.memoryTotalMb.toFixed(0)} MB`}
|
|
||||||
percent={(latest.memoryUsedMb / latest.memoryTotalMb) * 100}
|
|
||||||
warn={(latest.memoryUsedMb / latest.memoryTotalMb) >= 0.8}
|
|
||||||
critical={(latest.memoryUsedMb / latest.memoryTotalMb) >= 0.9}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Disque"
|
|
||||||
value={`${latest.diskUsedPercent.toFixed(0)}%`}
|
|
||||||
percent={latest.diskUsedPercent}
|
|
||||||
warn={latest.diskUsedPercent >= 80}
|
|
||||||
critical={latest.diskUsedPercent >= 90}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Heap Node"
|
|
||||||
value={`${latest.heapUsedMb.toFixed(0)} / ${latest.heapTotalMb.toFixed(0)} MB`}
|
|
||||||
percent={(latest.heapUsedMb / latest.heapTotalMb) * 100}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Load Avg (1m)"
|
|
||||||
value={latest.loadAvg1m.toFixed(2)}
|
|
||||||
warn={latest.loadAvg1m >= 2}
|
|
||||||
critical={latest.loadAvg1m >= 3}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="WiFi"
|
|
||||||
value={latest.wifiSsid ?? 'Non connecté'}
|
|
||||||
sub={latest.wifiSignalDbm !== null ? `${latest.wifiSignalDbm} dBm` : undefined}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Uptime"
|
|
||||||
value={formatUptime(latest.uptimeSeconds)}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Version"
|
|
||||||
value={`v${latest.clientVersion}`}
|
|
||||||
sub={latest.nodeVersion}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* History */}
|
|
||||||
{reports.length > 1 && (
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-slate-300">
|
|
||||||
Historique ({reports.length} rapports)
|
|
||||||
</h3>
|
|
||||||
<Button variant="ghost" className="text-xs" onClick={() => void fetchHealth()}>
|
|
||||||
Rafraîchir
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-48 overflow-y-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-slate-800 text-left text-slate-500">
|
|
||||||
<th className="pb-2 pr-3">Heure</th>
|
|
||||||
<th className="pb-2 pr-3">CPU</th>
|
|
||||||
<th className="pb-2 pr-3">RAM</th>
|
|
||||||
<th className="pb-2 pr-3">Disque</th>
|
|
||||||
<th className="pb-2 pr-3">Load</th>
|
|
||||||
<th className="pb-2">WiFi</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{reports.map((r, i) => (
|
|
||||||
<tr key={i} className="border-b border-slate-800/50 text-slate-400">
|
|
||||||
<td className="py-1.5 pr-3 font-mono">{formatTime(r.reportedAt)}</td>
|
|
||||||
<td className="py-1.5 pr-3">{r.cpuTempCelsius}°C</td>
|
|
||||||
<td className="py-1.5 pr-3">{r.memoryUsedMb.toFixed(0)} MB</td>
|
|
||||||
<td className="py-1.5 pr-3">{r.diskUsedPercent.toFixed(0)}%</td>
|
|
||||||
<td className="py-1.5 pr-3">{r.loadAvg1m.toFixed(2)}</td>
|
|
||||||
<td className="py-1.5">{r.wifiSignalDbm ?? '—'} dBm</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Logs tab ── */}
|
|
||||||
{tab === 'logs' && (
|
|
||||||
<>
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<select
|
|
||||||
value={logLevel}
|
|
||||||
onChange={(e) => setLogLevel(Number(e.target.value))}
|
|
||||||
className="rounded-xl border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm text-slate-200 focus:border-brand-400/60 focus:outline-none focus:ring-2 focus:ring-brand-400/40"
|
|
||||||
>
|
|
||||||
{LEVEL_OPTIONS.map((o) => (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Rechercher..."
|
|
||||||
value={logSearch}
|
|
||||||
onChange={(e) => setLogSearch(e.target.value)}
|
|
||||||
className="flex-1 rounded-xl border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-500 focus:border-brand-400/60 focus:outline-none focus:ring-2 focus:ring-brand-400/40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => void fetchLogs()}
|
|
||||||
loading={logsLoading}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Rafraîchir
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
{logsTotal} log{logsTotal > 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Log entries */}
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="max-h-[600px] overflow-y-auto font-mono text-xs">
|
|
||||||
{logsLoading && logs.length === 0 && (
|
|
||||||
<div className="p-8 text-center text-slate-400">Chargement...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!logsLoading && logs.length === 0 && (
|
|
||||||
<div className="p-8 text-center text-slate-400">Aucun log trouvé</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{logs.map((log, i) => {
|
|
||||||
const levelColor = LEVEL_COLORS[log.levelLabel] ?? 'text-slate-400';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={[
|
|
||||||
'flex gap-3 border-b border-slate-800/50 px-4 py-1.5',
|
|
||||||
'hover:bg-slate-800/30',
|
|
||||||
log.levelLabel === 'error' || log.levelLabel === 'fatal'
|
|
||||||
? 'bg-red-500/5'
|
|
||||||
: '',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<span className="w-16 shrink-0 text-slate-500">
|
|
||||||
{new Date(log.time).toLocaleTimeString('fr-FR')}
|
|
||||||
</span>
|
|
||||||
<span className={`w-12 shrink-0 uppercase ${levelColor}`}>
|
|
||||||
{log.levelLabel}
|
|
||||||
</span>
|
|
||||||
<span className="w-24 shrink-0 truncate text-slate-500">
|
|
||||||
{log.name ?? '—'}
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-300">{log.msg}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── MetricCard ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function MetricCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
sub,
|
|
||||||
percent,
|
|
||||||
warn = false,
|
|
||||||
critical = false,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
sub?: string;
|
|
||||||
percent?: number;
|
|
||||||
warn?: boolean;
|
|
||||||
critical?: boolean;
|
|
||||||
}) {
|
|
||||||
const borderColor = critical
|
|
||||||
? 'border-red-500/30'
|
|
||||||
: warn
|
|
||||||
? 'border-amber-500/30'
|
|
||||||
: 'border-slate-800';
|
|
||||||
|
|
||||||
const valueColor = critical
|
|
||||||
? 'text-red-400'
|
|
||||||
: warn
|
|
||||||
? 'text-amber-400'
|
|
||||||
: 'text-slate-100';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={`p-4 ${borderColor}`}>
|
|
||||||
<p className="text-xs text-slate-500">{label}</p>
|
|
||||||
<p className={`mt-1 text-lg font-semibold ${valueColor}`}>{value}</p>
|
|
||||||
{sub && <p className="mt-0.5 text-xs text-slate-500">{sub}</p>}
|
|
||||||
{percent !== undefined && (
|
|
||||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-800">
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'h-full rounded-full transition-all',
|
|
||||||
critical
|
|
||||||
? 'bg-red-500'
|
|
||||||
: warn
|
|
||||||
? 'bg-amber-500'
|
|
||||||
: 'bg-brand-500',
|
|
||||||
].join(' ')}
|
|
||||||
style={{ width: `${Math.min(percent, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
|
||||||
import { Button, Card, Input } from '../components/ui';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
import { ApiError } from '../lib/api';
|
|
||||||
|
|
||||||
interface LocationState {
|
|
||||||
from?: { pathname: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoginPage() {
|
|
||||||
const { login } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const from = (location.state as LocationState | null)?.from?.pathname || '/';
|
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await login({ email, password });
|
|
||||||
navigate(from, { replace: true });
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
setError(
|
|
||||||
err.status === 401
|
|
||||||
? 'Email ou mot de passe incorrect.'
|
|
||||||
: err.message || 'Erreur de connexion.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setError('Impossible de joindre le serveur.');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-full items-center justify-center p-8">
|
|
||||||
<Card className="w-full max-w-md p-8">
|
|
||||||
<header className="mb-6 text-center">
|
|
||||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-500/10 text-3xl">
|
|
||||||
🤖
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Bon retour !</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">Connecte-toi à ton Ti-Pote</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
|
||||||
<Input
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
placeholder="toi@exemple.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Mot de passe"
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit" loading={submitting} className="mt-2 w-full">
|
|
||||||
Se connecter
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-slate-400">
|
|
||||||
Pas encore de compte ?{' '}
|
|
||||||
<Link to="/register" className="font-medium text-brand-400 hover:text-brand-300">
|
|
||||||
Créer un compte
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
import { useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { Button, Card } from '../components/ui';
|
|
||||||
import { api, ApiError } from '../lib/api';
|
|
||||||
|
|
||||||
const CODE_LENGTH = 6;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pairing flow (user side).
|
|
||||||
*
|
|
||||||
* The robot-client calls POST /pairing/request on first boot, displays a
|
|
||||||
* 6-digit code, and polls GET /pairing/status/:requestId until it sees a
|
|
||||||
* `confirmed` response.
|
|
||||||
*
|
|
||||||
* Here we just collect the 6 digits from the user and POST them to
|
|
||||||
* /pairing/confirm — the backend then flips the pairing request to
|
|
||||||
* confirmed and the robot picks up its device credentials on its next poll.
|
|
||||||
*/
|
|
||||||
export function PairRobotPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [digits, setDigits] = useState<string[]>(() =>
|
|
||||||
Array.from({ length: CODE_LENGTH }, () => ''),
|
|
||||||
);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<{ deviceId: string; deviceName: string } | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
|
|
||||||
|
|
||||||
const code = useMemo(() => digits.join(''), [digits]);
|
|
||||||
const isComplete = code.length === CODE_LENGTH && digits.every((d) => /\d/.test(d));
|
|
||||||
|
|
||||||
function setDigitAt(index: number, value: string) {
|
|
||||||
const cleaned = value.replace(/\D/g, '').slice(0, 1);
|
|
||||||
setDigits((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
next[index] = cleaned;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (cleaned && index < CODE_LENGTH - 1) {
|
|
||||||
inputsRef.current[index + 1]?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown(index: number, e: KeyboardEvent<HTMLInputElement>) {
|
|
||||||
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
|
||||||
inputsRef.current[index - 1]?.focus();
|
|
||||||
} else if (e.key === 'ArrowLeft' && index > 0) {
|
|
||||||
inputsRef.current[index - 1]?.focus();
|
|
||||||
} else if (e.key === 'ArrowRight' && index < CODE_LENGTH - 1) {
|
|
||||||
inputsRef.current[index + 1]?.focus();
|
|
||||||
} else if (e.key === 'Enter' && isComplete) {
|
|
||||||
void submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPaste(e: ClipboardEvent<HTMLInputElement>) {
|
|
||||||
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
|
|
||||||
if (!pasted) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const next = Array.from({ length: CODE_LENGTH }, (_, i) => pasted[i] ?? '');
|
|
||||||
setDigits(next);
|
|
||||||
const lastFilled = Math.min(pasted.length, CODE_LENGTH) - 1;
|
|
||||||
inputsRef.current[lastFilled < 0 ? 0 : lastFilled]?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
setError(null);
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await api.confirmPairing(code);
|
|
||||||
setSuccess(result);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
if (err.status === 400) {
|
|
||||||
setError('Code invalide ou expiré. Vérifie le code affiché sur le robot.');
|
|
||||||
} else {
|
|
||||||
setError(err.message || "Erreur lors de l'association.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError('Impossible de joindre le serveur.');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Success screen ─────────────────────────────────────────────
|
|
||||||
if (success) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-full items-center justify-center p-8">
|
|
||||||
<Card className="w-full max-w-md p-8 text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 text-4xl">
|
|
||||||
✅
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Robot associé !</h1>
|
|
||||||
<p className="mt-2 text-sm text-slate-400">
|
|
||||||
<span className="font-medium text-slate-200">{success.deviceName}</span> fait
|
|
||||||
maintenant partie de ton foyer.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-xs text-slate-500">{success.deviceId}</p>
|
|
||||||
|
|
||||||
<div className="mt-6 flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => {
|
|
||||||
setSuccess(null);
|
|
||||||
setDigits(Array.from({ length: CODE_LENGTH }, () => ''));
|
|
||||||
inputsRef.current[0]?.focus();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Associer un autre
|
|
||||||
</Button>
|
|
||||||
<Button className="flex-1" onClick={() => navigate('/')}>
|
|
||||||
Terminer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Input screen ───────────────────────────────────────────────
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-full items-center justify-center p-8">
|
|
||||||
<Card className="w-full max-w-md p-8">
|
|
||||||
<header className="mb-6 text-center">
|
|
||||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-500/10 text-3xl">
|
|
||||||
🔗
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Associer un robot</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Saisis le code à 6 chiffres affiché sur ton Ti-Pote
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void submit();
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-5"
|
|
||||||
>
|
|
||||||
<div className="flex justify-center gap-2" onPaste={onPaste}>
|
|
||||||
{digits.map((d, i) => (
|
|
||||||
<input
|
|
||||||
key={i}
|
|
||||||
ref={(el) => {
|
|
||||||
inputsRef.current[i] = el;
|
|
||||||
}}
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="\d*"
|
|
||||||
maxLength={1}
|
|
||||||
value={d}
|
|
||||||
onChange={(e) => setDigitAt(i, e.target.value)}
|
|
||||||
onKeyDown={(e) => onKeyDown(i, e)}
|
|
||||||
onFocus={(e) => e.target.select()}
|
|
||||||
className="h-14 w-12 rounded-xl border border-slate-700 bg-slate-900/60 text-center text-2xl font-semibold tabular-nums text-slate-100 shadow-inner focus:border-brand-400/60 focus:outline-none focus:ring-2 focus:ring-brand-400/40"
|
|
||||||
aria-label={`Chiffre ${i + 1}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-center text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit" loading={submitting} disabled={!isComplete} className="w-full">
|
|
||||||
Associer ce robot
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-6 flex items-center justify-center gap-1 text-sm text-slate-400">
|
|
||||||
<Link to="/" className="hover:text-slate-200">
|
|
||||||
← Retour au tableau de bord
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 rounded-lg border border-slate-800 bg-slate-950/50 p-4 text-xs text-slate-500">
|
|
||||||
<p className="mb-1 font-medium text-slate-400">💡 Comment obtenir le code ?</p>
|
|
||||||
<p>
|
|
||||||
Allume ton Ti-Pote. Lors du premier démarrage, il annonce vocalement un code à
|
|
||||||
6 chiffres et l'affiche (écran / LED / logs). Ce code n'est valable que 10 minutes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { Button, Card, Input } from '../components/ui';
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
import { ApiError } from '../lib/api';
|
|
||||||
|
|
||||||
export function RegisterPage() {
|
|
||||||
const { register } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [displayName, setDisplayName] = useState('');
|
|
||||||
const [homeName, setHomeName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (password.length < 8) {
|
|
||||||
setError('Le mot de passe doit faire au moins 8 caractères.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await register({ email, password, displayName, homeName });
|
|
||||||
navigate('/', { replace: true });
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
setError(
|
|
||||||
err.status === 409
|
|
||||||
? 'Cette adresse email est déjà utilisée.'
|
|
||||||
: err.message || "Erreur lors de l'inscription.",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setError('Impossible de joindre le serveur.');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-full items-center justify-center p-8">
|
|
||||||
<Card className="w-full max-w-md p-8">
|
|
||||||
<header className="mb-6 text-center">
|
|
||||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-500/10 text-3xl">
|
|
||||||
✨
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Crée ton compte</h1>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Associe un Ti-Pote à ta maison en quelques secondes
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
|
||||||
<Input
|
|
||||||
label="Ton prénom"
|
|
||||||
name="displayName"
|
|
||||||
autoComplete="name"
|
|
||||||
required
|
|
||||||
placeholder="Arthur"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Nom de ton foyer"
|
|
||||||
name="homeName"
|
|
||||||
required
|
|
||||||
placeholder="Chez moi"
|
|
||||||
value={homeName}
|
|
||||||
onChange={(e) => setHomeName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
placeholder="toi@exemple.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Mot de passe"
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
placeholder="8 caractères minimum"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit" loading={submitting} className="mt-2 w-full">
|
|
||||||
Créer mon compte
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-slate-400">
|
|
||||||
Déjà un compte ?{' '}
|
|
||||||
<Link to="/login" className="font-medium text-brand-400 hover:text-brand-300">
|
|
||||||
Se connecter
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,534 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { Button, Card, Input } from '../components/ui';
|
|
||||||
import { api, ApiError } from '../lib/api';
|
|
||||||
import {
|
|
||||||
createRobotLocalApi,
|
|
||||||
type RobotLocalApi,
|
|
||||||
type WifiNetwork,
|
|
||||||
} from '../lib/robot-local-api';
|
|
||||||
|
|
||||||
// ─── Types ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type Step = 'connect-ap' | 'wifi-select' | 'wifi-password' | 'wifi-connecting' | 'pairing';
|
|
||||||
|
|
||||||
// ─── Component ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function SetupRobotPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [step, setStep] = useState<Step>('connect-ap');
|
|
||||||
const [robotApi] = useState<RobotLocalApi>(() => createRobotLocalApi());
|
|
||||||
const [robotName, setRobotName] = useState('Ti-Pote');
|
|
||||||
|
|
||||||
// WiFi state
|
|
||||||
const [networks, setNetworks] = useState<WifiNetwork[]>([]);
|
|
||||||
const [scanning, setScanning] = useState(false);
|
|
||||||
const [selectedSsid, setSelectedSsid] = useState('');
|
|
||||||
const [wifiPassword, setWifiPassword] = useState('');
|
|
||||||
const [wifiError, setWifiError] = useState<string | null>(null);
|
|
||||||
const [wifiConnecting, setWifiConnecting] = useState(false);
|
|
||||||
|
|
||||||
// Pairing state
|
|
||||||
const CODE_LENGTH = 6;
|
|
||||||
const [digits, setDigits] = useState<string[]>(() => Array.from({ length: CODE_LENGTH }, () => ''));
|
|
||||||
const [pairingSubmitting, setPairingSubmitting] = useState(false);
|
|
||||||
const [pairingError, setPairingError] = useState<string | null>(null);
|
|
||||||
const [pairingSuccess, setPairingSuccess] = useState<{ deviceId: string; deviceName: string } | null>(null);
|
|
||||||
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
|
|
||||||
const code = useMemo(() => digits.join(''), [digits]);
|
|
||||||
const isCodeComplete = code.length === CODE_LENGTH && digits.every((d) => /\d/.test(d));
|
|
||||||
|
|
||||||
// ── Step 1: Detect robot AP ──────────────────────────────────────
|
|
||||||
|
|
||||||
const [detecting, setDetecting] = useState(false);
|
|
||||||
const [detected, setDetected] = useState(false);
|
|
||||||
|
|
||||||
const detectRobot = useCallback(async () => {
|
|
||||||
setDetecting(true);
|
|
||||||
try {
|
|
||||||
const reachable = await robotApi.isReachable();
|
|
||||||
if (reachable) {
|
|
||||||
const status = await robotApi.getStatus();
|
|
||||||
setRobotName(status.robotName);
|
|
||||||
setDetected(true);
|
|
||||||
} else {
|
|
||||||
setDetected(false);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setDetected(false);
|
|
||||||
} finally {
|
|
||||||
setDetecting(false);
|
|
||||||
}
|
|
||||||
}, [robotApi]);
|
|
||||||
|
|
||||||
// Auto-detect every 3s on step 1
|
|
||||||
useEffect(() => {
|
|
||||||
if (step !== 'connect-ap') return;
|
|
||||||
detectRobot();
|
|
||||||
const interval = setInterval(detectRobot, 3000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [step, detectRobot]);
|
|
||||||
|
|
||||||
// ── Step 2: Scan WiFi ────────────────────────────────────────────
|
|
||||||
|
|
||||||
const scanNetworks = useCallback(async () => {
|
|
||||||
setScanning(true);
|
|
||||||
try {
|
|
||||||
const results = await robotApi.scanWifi();
|
|
||||||
// Sort by signal strength, deduplicate by SSID
|
|
||||||
const unique = new Map<string, WifiNetwork>();
|
|
||||||
for (const n of results) {
|
|
||||||
if (n.ssid && (!unique.has(n.ssid) || unique.get(n.ssid)!.signal < n.signal)) {
|
|
||||||
unique.set(n.ssid, n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setNetworks(
|
|
||||||
Array.from(unique.values()).sort((a, b) => b.signal - a.signal),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setNetworks([]);
|
|
||||||
} finally {
|
|
||||||
setScanning(false);
|
|
||||||
}
|
|
||||||
}, [robotApi]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (step === 'wifi-select') {
|
|
||||||
scanNetworks();
|
|
||||||
}
|
|
||||||
}, [step, scanNetworks]);
|
|
||||||
|
|
||||||
// ── Step 3: Connect WiFi ─────────────────────────────────────────
|
|
||||||
|
|
||||||
async function handleWifiConnect() {
|
|
||||||
setWifiError(null);
|
|
||||||
setWifiConnecting(true);
|
|
||||||
setStep('wifi-connecting');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await robotApi.connectWifi(selectedSsid, wifiPassword);
|
|
||||||
if (result.success) {
|
|
||||||
// Robot is now on user's WiFi — we lost AP connection
|
|
||||||
// Wait a moment then move to pairing step
|
|
||||||
setTimeout(() => setStep('pairing'), 3000);
|
|
||||||
} else {
|
|
||||||
setWifiError(result.error || 'Connexion échouée');
|
|
||||||
setStep('wifi-password');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Likely lost connection because AP went down (expected on success)
|
|
||||||
// Move to pairing after a delay
|
|
||||||
setTimeout(() => setStep('pairing'), 3000);
|
|
||||||
} finally {
|
|
||||||
setWifiConnecting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 4: Pairing ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
function setDigitAt(index: number, value: string) {
|
|
||||||
const cleaned = value.replace(/\D/g, '').slice(0, 1);
|
|
||||||
setDigits((prev) => {
|
|
||||||
const next = [...prev];
|
|
||||||
next[index] = cleaned;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (cleaned && index < CODE_LENGTH - 1) {
|
|
||||||
inputsRef.current[index + 1]?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeyDown(index: number, e: KeyboardEvent<HTMLInputElement>) {
|
|
||||||
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
|
||||||
inputsRef.current[index - 1]?.focus();
|
|
||||||
} else if (e.key === 'ArrowLeft' && index > 0) {
|
|
||||||
inputsRef.current[index - 1]?.focus();
|
|
||||||
} else if (e.key === 'ArrowRight' && index < CODE_LENGTH - 1) {
|
|
||||||
inputsRef.current[index + 1]?.focus();
|
|
||||||
} else if (e.key === 'Enter' && isCodeComplete) {
|
|
||||||
void submitPairing();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPaste(e: ClipboardEvent<HTMLInputElement>) {
|
|
||||||
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
|
|
||||||
if (!pasted) return;
|
|
||||||
e.preventDefault();
|
|
||||||
const next = Array.from({ length: CODE_LENGTH }, (_, i) => pasted[i] ?? '');
|
|
||||||
setDigits(next);
|
|
||||||
const lastFilled = Math.min(pasted.length, CODE_LENGTH) - 1;
|
|
||||||
inputsRef.current[lastFilled < 0 ? 0 : lastFilled]?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitPairing() {
|
|
||||||
setPairingError(null);
|
|
||||||
setPairingSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await api.confirmPairing(code);
|
|
||||||
setPairingSuccess(result);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
if (err.status === 400) {
|
|
||||||
setPairingError('Code invalide ou expiré. Vérifie le code affiché sur le robot.');
|
|
||||||
} else {
|
|
||||||
setPairingError(err.message || "Erreur lors de l'association.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setPairingError('Impossible de joindre le serveur. Reconnecte-toi à ton WiFi habituel.');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setPairingSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Signal icon helper ───────────────────────────────────────────
|
|
||||||
|
|
||||||
function signalIcon(signal: number): string {
|
|
||||||
if (signal >= 70) return '▂▄▆█';
|
|
||||||
if (signal >= 50) return '▂▄▆░';
|
|
||||||
if (signal >= 30) return '▂▄░░';
|
|
||||||
return '▂░░░';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step progress indicator ──────────────────────────────────────
|
|
||||||
|
|
||||||
const steps = [
|
|
||||||
{ key: 'connect-ap', label: 'Connexion' },
|
|
||||||
{ key: 'wifi-select', label: 'WiFi' },
|
|
||||||
{ key: 'pairing', label: 'Association' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const currentStepIndex = (() => {
|
|
||||||
if (step === 'connect-ap') return 0;
|
|
||||||
if (step === 'wifi-select' || step === 'wifi-password' || step === 'wifi-connecting') return 1;
|
|
||||||
return 2;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ─── Render ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Success screen
|
|
||||||
if (pairingSuccess) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-full items-center justify-center p-8">
|
|
||||||
<Card className="w-full max-w-md p-8 text-center">
|
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 text-4xl">
|
|
||||||
🎉
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
|
||||||
{robotName} est prêt !
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-slate-400">
|
|
||||||
Le robot est connecté au WiFi et associé à ton foyer.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-xs text-slate-500">{pairingSuccess.deviceId}</p>
|
|
||||||
|
|
||||||
<div className="mt-6 flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => navigate('/setup')}
|
|
||||||
>
|
|
||||||
Ajouter un autre
|
|
||||||
</Button>
|
|
||||||
<Button className="flex-1" onClick={() => navigate('/')}>
|
|
||||||
Tableau de bord
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-full items-center justify-center p-8">
|
|
||||||
<Card className="w-full max-w-lg p-8">
|
|
||||||
{/* Step indicator */}
|
|
||||||
<div className="mb-8 flex items-center justify-center gap-2">
|
|
||||||
{steps.map((s, i) => (
|
|
||||||
<div key={s.key} className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold transition-all',
|
|
||||||
i < currentStepIndex
|
|
||||||
? 'bg-emerald-500 text-white'
|
|
||||||
: i === currentStepIndex
|
|
||||||
? 'bg-brand-500 text-white ring-4 ring-brand-500/20'
|
|
||||||
: 'bg-slate-800 text-slate-500',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{i < currentStepIndex ? '✓' : i + 1}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={[
|
|
||||||
'text-xs font-medium',
|
|
||||||
i === currentStepIndex ? 'text-slate-200' : 'text-slate-500',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
{i < steps.length - 1 && (
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
'mx-1 h-px w-8',
|
|
||||||
i < currentStepIndex ? 'bg-emerald-500' : 'bg-slate-700',
|
|
||||||
].join(' ')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Step 1: Connect to AP ── */}
|
|
||||||
{step === 'connect-ap' && (
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center">
|
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-500/10 text-4xl">
|
|
||||||
📡
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold">Connecte-toi au robot</h2>
|
|
||||||
<p className="mt-2 text-sm text-slate-400">
|
|
||||||
Ouvre les paramètres WiFi de ton ordinateur et connecte-toi au réseau :
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 inline-block rounded-lg border border-brand-500/30 bg-brand-500/5 px-4 py-2">
|
|
||||||
<span className="font-mono text-lg font-bold text-brand-400">Ti-Pote</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
|
||||||
Pas de mot de passe requis
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-col items-center gap-3">
|
|
||||||
{detecting && !detected && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
|
||||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="opacity-25" />
|
|
||||||
<path fill="currentColor" className="opacity-75" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
||||||
</svg>
|
|
||||||
Recherche du robot...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detected && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-emerald-400">
|
|
||||||
<span>✓</span>
|
|
||||||
<span>Robot détecté : <strong>{robotName}</strong></span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
disabled={!detected}
|
|
||||||
onClick={() => setStep('wifi-select')}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{detected ? 'Configurer le WiFi' : 'En attente de connexion...'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 2: WiFi selection ── */}
|
|
||||||
{step === 'wifi-select' && (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-xl font-semibold">Choisis ton réseau WiFi</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Le robot se connectera à ce réseau pour accéder à internet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
{networks.length} réseau{networks.length > 1 ? 'x' : ''} trouvé{networks.length > 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => void scanNetworks()}
|
|
||||||
loading={scanning}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Rescanner
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{scanning && networks.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-8 text-sm text-slate-400">
|
|
||||||
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="opacity-25" />
|
|
||||||
<path fill="currentColor" className="opacity-75" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
||||||
</svg>
|
|
||||||
Scan en cours...
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="max-h-64 overflow-y-auto rounded-xl border border-slate-800">
|
|
||||||
{networks.map((n) => (
|
|
||||||
<button
|
|
||||||
key={n.ssid}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedSsid(n.ssid);
|
|
||||||
setStep('wifi-password');
|
|
||||||
}}
|
|
||||||
className={[
|
|
||||||
'flex w-full items-center justify-between px-4 py-3',
|
|
||||||
'border-b border-slate-800 last:border-b-0',
|
|
||||||
'text-left text-sm transition-colors',
|
|
||||||
'hover:bg-slate-800/60',
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-slate-400">
|
|
||||||
{n.security !== '--' && n.security !== '' ? '🔒' : '🔓'}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-slate-200">{n.ssid}</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-mono text-xs text-slate-500" title={`${n.signal}%`}>
|
|
||||||
{signalIcon(n.signal)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button variant="secondary" onClick={() => setStep('connect-ap')}>
|
|
||||||
← Retour
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 2b: WiFi password ── */}
|
|
||||||
{step === 'wifi-password' && (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-xl font-semibold">Mot de passe WiFi</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Réseau : <strong className="text-slate-200">{selectedSsid}</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleWifiConnect();
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
label="Mot de passe"
|
|
||||||
type="password"
|
|
||||||
value={wifiPassword}
|
|
||||||
onChange={(e) => setWifiPassword(e.target.value)}
|
|
||||||
error={wifiError}
|
|
||||||
autoFocus
|
|
||||||
placeholder="Mot de passe du réseau WiFi"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setWifiPassword('');
|
|
||||||
setWifiError(null);
|
|
||||||
setStep('wifi-select');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
← Autre réseau
|
|
||||||
</Button>
|
|
||||||
<Button className="flex-1" type="submit" loading={wifiConnecting}>
|
|
||||||
Connecter
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 2c: Connecting... ── */}
|
|
||||||
{step === 'wifi-connecting' && (
|
|
||||||
<div className="flex flex-col items-center gap-6 py-8 text-center">
|
|
||||||
<svg className="h-12 w-12 animate-spin text-brand-400" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="opacity-25" />
|
|
||||||
<path fill="currentColor" className="opacity-75" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold">Connexion en cours...</h2>
|
|
||||||
<p className="mt-2 text-sm text-slate-400">
|
|
||||||
Le robot se connecte au réseau <strong className="text-slate-200">{selectedSsid}</strong>.
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
Le hotspot va se fermer. Reconnecte-toi à ton WiFi habituel.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 3: Pairing code ── */}
|
|
||||||
{step === 'pairing' && (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-500/10 text-3xl">
|
|
||||||
🔗
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold">Associer le robot</h2>
|
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
Saisis le code à 6 chiffres affiché sur {robotName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-slate-800 bg-slate-950/50 p-3 text-xs text-slate-500">
|
|
||||||
<p className="mb-1 font-medium text-slate-400">💡 Reconnecte-toi à ton WiFi</p>
|
|
||||||
<p>
|
|
||||||
Le robot est maintenant sur ton WiFi. Reconnecte ton ordinateur à ton réseau
|
|
||||||
habituel pour pouvoir communiquer avec le serveur.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void submitPairing();
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex justify-center gap-2" onPaste={onPaste}>
|
|
||||||
{digits.map((d, i) => (
|
|
||||||
<input
|
|
||||||
key={i}
|
|
||||||
ref={(el) => {
|
|
||||||
inputsRef.current[i] = el;
|
|
||||||
}}
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="\d*"
|
|
||||||
maxLength={1}
|
|
||||||
value={d}
|
|
||||||
onChange={(e) => setDigitAt(i, e.target.value)}
|
|
||||||
onKeyDown={(e) => onKeyDown(i, e)}
|
|
||||||
onFocus={(e) => e.target.select()}
|
|
||||||
className="h-14 w-12 rounded-xl border border-slate-700 bg-slate-900/60 text-center text-2xl font-semibold tabular-nums text-slate-100 shadow-inner focus:border-brand-400/60 focus:outline-none focus:ring-2 focus:ring-brand-400/40"
|
|
||||||
aria-label={`Chiffre ${i + 1}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pairingError && (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-center text-sm text-red-400">
|
|
||||||
{pairingError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit" loading={pairingSubmitting} disabled={!isCodeComplete} className="w-full">
|
|
||||||
Associer ce robot
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bottom link */}
|
|
||||||
<div className="mt-6 flex items-center justify-center gap-1 text-sm text-slate-400">
|
|
||||||
<Link to="/" className="hover:text-slate-200">
|
|
||||||
← Retour au tableau de bord
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||