Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# FRONT END
NEXT_PUBLIC_API_URL=http://localhost:3001/api/
NEXT_PUBLIC_FRONT_URL=
NEXT_PUBLIC_ROSBRIDGE_URL=ws://localhost:9090
36 changes: 26 additions & 10 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,45 @@
## PR Type
## TroubleShooting

<!-- feature | fix | refactor | docs | chore | hotfix -->
<!-- issue number or pr number -->

## Summary

<!-- 1-2 line summary -->

## Description

<!-- Context, what, why, how to test -->
<!-- Context, what, why, -->

## What's Changed

- [ ]
- [ ]
- [ ]
<!-- Group by domain/responsibility, not individual files -->

**Affected areas:**

- User authentication & validation layer
- Email handling utilities
- Related middleware

<!-- List only the critical files to review first -->

**Key files:** `UserService.ts`, `AuthMiddleware.ts`, `EmailValidator.ts` (new)

<!-- Acknowledge reviewers can dig deeper if needed -->

**Details:** See commits or ask in thread for specific file breakdown

## How to Test

<!-- Specific steps or commands to validate -->

## Screen

<!-- Optional — screenshots if UI change -->
<!-- Optional — screenshots if UI change.If backend on need to make N/A just delete the section-->

## Will
## Related

<!-- Optional — follow-up to another issue, e.g.: Relates to #XX or To be continued in #XX -->
<!-- Optional — Relates to #XX, Depends on #XX, or Follow-up: #XX -->

---

<!-- Don't forget: Closes #XX or Fixes #XX -->
Closes #XX or Fixes #XX
2 changes: 1 addition & 1 deletion src/architecture/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HttpError, apiClient } from './client';
import { HttpError, apiClient } from './rest.client';

const baseUrl = 'http://localhost:3001/api/';
let mockFetch: ReturnType<typeof vi.fn>;
Expand Down
File renamed without changes.
28 changes: 28 additions & 0 deletions src/architecture/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,31 @@ export interface WebSocketClientCallbacks<TMessage = unknown> {
onClose?: (event: CloseEvent) => void;
onError?: (event: Event) => void;
}

// --- Robot WebSocket Messages ---

export interface RobotState {
is_connected: boolean;
battery_level: number;
linear_velocity: number;
ping_ms: number;
last_updated: string;
}

export interface HealthData {
status: string;
adapter: string;
environment: string;
robot_state: RobotState;
}

export type WsIncomingMessage =
| { type: 'health_response'; data: HealthData }
| { type: 'initial_state'; data: RobotState }
| { type: 'robot_state_updated'; data: RobotState }
| { type: 'pong' };

export type WsOutgoingMessage =
| { type: 'get_health' }
| { type: 'ping' }
| { type: 'get_state' };
12 changes: 6 additions & 6 deletions src/architecture/api/ws.client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,19 @@ afterEach(() => {
});

describe('getWsBaseUrl via env override', () => {
const originalEnv = process.env.NEXT_PUBLIC_API_URL;
const originalEnv = process.env.NEXT_PUBLIC_ROSBRIDGE_URL;

afterEach(() => {
if (originalEnv === undefined) {
delete process.env.NEXT_PUBLIC_API_URL;
delete process.env.NEXT_PUBLIC_ROSBRIDGE_URL;
} else {
process.env.NEXT_PUBLIC_API_URL = originalEnv;
process.env.NEXT_PUBLIC_ROSBRIDGE_URL = originalEnv;
}
});

it('converts https to wss', async () => {
vi.resetModules();
process.env.NEXT_PUBLIC_API_URL = 'https://robot.example.com/api/';
process.env.NEXT_PUBLIC_ROSBRIDGE_URL = 'https://robot.example.com:9090';
const { WebSocketClient: WsClient } = await import('./ws.client');
const client = new WsClient({ path: 'status' });
client.connect();
Expand All @@ -110,11 +110,11 @@ describe('getWsBaseUrl via env override', () => {

it('prepends ws:// when URL has no protocol', async () => {
vi.resetModules();
process.env.NEXT_PUBLIC_API_URL = 'robot.local:9090/api/';
process.env.NEXT_PUBLIC_ROSBRIDGE_URL = 'robot.local:9090';
const { WebSocketClient: WsClient } = await import('./ws.client');
const client = new WsClient({ path: 'feed' });
client.connect();
expect(capturedUrl).toBe('ws://robot.local:9090/api/feed');
expect(capturedUrl).toBe('ws://robot.local:9090/feed');
});
});

Expand Down
7 changes: 4 additions & 3 deletions src/architecture/api/ws.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import {
* Connection lifecycle, optional reconnect, typed message handling.
*/

const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/';
const ROSBRIDGE_URL =
process.env.NEXT_PUBLIC_ROSBRIDGE_URL || 'ws://localhost:8765';

/** Builds WebSocket URL from HTTP base (http(s) -> ws(s)). */
function getWsBaseUrl(): string {
const url = API_BASE_URL.trim().replace(/\/$/, '');
const url = ROSBRIDGE_URL.trim().replace(/\/$/, '');
if (url.startsWith('https://')) return url.replace('https://', 'wss://');
if (url.startsWith('http://')) return url.replace('http://', 'ws://');
if (url.startsWith('wss://') || url.startsWith('ws://')) return url;
return `ws://${url}`;
}

Expand Down
41 changes: 41 additions & 0 deletions src/architecture/gateways/robot.gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { WebSocketClient } from '../api/ws.client';
import type {
WsIncomingMessage,
WebSocketClientCallbacks,
WebSocketConnectionState,
} from '../api/types';

export class RobotGateway {
private client: WebSocketClient<WsIncomingMessage>;

constructor() {
this.client = new WebSocketClient<WsIncomingMessage>({ path: '' });
}

connect(callbacks: WebSocketClientCallbacks<WsIncomingMessage>): void {
this.client.setCallbacks(callbacks);
this.client.connect();
}

disconnect(): void {
this.client.disconnect();
}

get connectionState(): WebSocketConnectionState {
return this.client.connectionState;
}

getHealth(): void {
this.client.send({ type: 'get_health' });
}

ping(): void {
this.client.send({ type: 'ping' });
}

getState(): void {
this.client.send({ type: 'get_state' });
}
}

export const robotGateway = new RobotGateway();
10 changes: 8 additions & 2 deletions src/components/dashboard/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
'use client';

import React, { useState } from 'react';
import { useState } from 'react';
import { Toaster, toast } from 'sonner';
import { Header } from '@/components/dashboard/header';
import { CameraFeed } from '@/components/dashboard/camera-feed';
import { ManualControl } from '@/components/dashboard/manual-control';
import { TaskManager } from '@/components/dashboard/task-manager';
import { MetricsAndAlerts } from '@/components/dashboard/metrics-and-alerts';
import { useRobotHealth } from '@/hooks/use-robot-health';

const vitalsData = [
{ time: '10:00', heartRate: 72, battery: 95 },
Expand All @@ -22,6 +23,7 @@ export function Dashboard() {
const [isAutonomous, setIsAutonomous] = useState(true);
const [speed, setSpeed] = useState([50]);
const [activeTask, setActiveTask] = useState<string | null>(null);
const { connectionState, health } = useRobotHealth();

const handleStartTask = (taskName: string) => {
setActiveTask(taskName);
Expand All @@ -45,7 +47,11 @@ export function Dashboard() {
<div className="min-h-screen bg-background font-sans text-foreground">
<Toaster position="top-right" />

<Header isAutonomous={isAutonomous} />
<Header
isAutonomous={isAutonomous}
connectionState={connectionState}
health={health}
/>

<main className="p-6 max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-4 space-y-6">
Expand Down
32 changes: 26 additions & 6 deletions src/components/dashboard/header.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
'use client';

import React from 'react';
import Image from 'next/image';
import { Battery, Wifi } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import type {
WebSocketConnectionState,
HealthData,
} from '@/architecture/api/types';

interface HeaderProps {
isAutonomous: boolean;
connectionState?: WebSocketConnectionState;
health?: HealthData | null;
}

export function Header({ isAutonomous }: Readonly<HeaderProps>) {
export function Header({
isAutonomous,
connectionState = 'closed',
health,
}: Readonly<HeaderProps>) {
const isConnected = connectionState === 'open';
const robotState = health?.robot_state;
const battery = robotState?.battery_level ?? 0;
const ping = robotState?.ping_ms ?? 0;
return (
<header className="bg-white border-b border-zinc-200 sticky top-0 z-10 px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
Expand All @@ -31,14 +45,20 @@ export function Header({ isAutonomous }: Readonly<HeaderProps>) {

<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Wifi className="h-5 w-5 text-green-500" />
<Wifi
className={`h-5 w-5 ${isConnected ? 'text-green-500' : 'text-red-500'}`}
/>
<span className="text-sm font-medium text-foreground">
Connecté (Ping: 12ms)
{isConnected ? `Connecté (Ping: ${ping}ms)` : 'Déconnecté'}
</span>
</div>
<div className="flex items-center gap-2">
<Battery className="h-5 w-5 text-green-500" />
<span className="text-sm font-medium text-foreground">60%</span>
<Battery
className={`h-5 w-5 ${battery > 50 ? 'text-green-500' : battery > 20 ? 'text-yellow-500' : 'text-red-500'}`}
/>
<span className="text-sm font-medium text-foreground">
{battery.toFixed(1)}%
</span>
</div>
<Badge
variant={isAutonomous ? 'success' : 'warning'}
Expand Down
45 changes: 45 additions & 0 deletions src/hooks/use-robot-health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client';

import { useEffect, useState } from 'react';
import { robotGateway } from '@/architecture/gateways/robot.gateway';
import type {
HealthData,
WebSocketConnectionState,
WsIncomingMessage,
} from '@/architecture/api/types';

export function useRobotHealth() {
const [connectionState, setConnectionState] =
useState<WebSocketConnectionState>('closed');
const [health, setHealth] = useState<HealthData | null>(null);

useEffect(() => {
robotGateway.connect({
onOpen: () => {
setConnectionState('open');
robotGateway.getHealth();
},
onMessage: (msg: WsIncomingMessage) => {
if (msg.type === 'health_response') {
setHealth(msg.data);
}
},
onClose: () => {
setConnectionState('closed');
},
onError: () => {
setConnectionState('closed');
},
});

return () => {
robotGateway.disconnect();
};
}, []);

return {
connectionState,
health,
checkHealth: () => robotGateway.getHealth(),
};
}
Loading