feat: add WiFi setup wizard and device detail dashboard (Phase 4)
- SetupRobotPage: 4-step wizard (AP connect → WiFi scan → connect → pairing code) - DeviceDetailPage: health metrics dashboard + log viewer with filters - Robot local API client for captive portal communication (192.168.4.1) - API endpoints for health reports, alerts, and device logs - Dashboard: clickable device cards linking to detail, "Nouveau robot" button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
096f772da8
commit
6760759cb6
@ -5,6 +5,8 @@ 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.
|
||||
@ -45,6 +47,22 @@ export function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/setup"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SetupRobotPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/devices/:deviceId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DeviceDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@ -62,6 +62,58 @@ export interface PairingConfirmResult {
|
||||
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 {
|
||||
@ -230,4 +282,27 @@ export const api = {
|
||||
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}` : ''}`);
|
||||
},
|
||||
};
|
||||
|
||||
112
apps/frontend/src/lib/robot-local-api.ts
Normal file
112
apps/frontend/src/lib/robot-local-api.ts
Normal file
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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>;
|
||||
@ -50,8 +50,11 @@ export function DashboardPage() {
|
||||
<Button variant="secondary" onClick={() => void fetchDevices()}>
|
||||
Rafraîchir
|
||||
</Button>
|
||||
<Link to="/setup">
|
||||
<Button>+ Nouveau robot</Button>
|
||||
</Link>
|
||||
<Link to="/pair">
|
||||
<Button>+ Associer un robot</Button>
|
||||
<Button variant="secondary">Code uniquement</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@ -78,8 +81,8 @@ export function DashboardPage() {
|
||||
Allume ton Ti-Pote puis associe-le avec le code qui s'affichera.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/pair">
|
||||
<Button>Associer un robot</Button>
|
||||
<Link to="/setup">
|
||||
<Button>Configurer un robot</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
)}
|
||||
@ -87,29 +90,33 @@ export function DashboardPage() {
|
||||
{!loading && !error && devices && devices.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{devices.map((d) => (
|
||||
<Card key={d.id} className="p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-base font-medium">{d.name}</h3>
|
||||
<p className="mt-0.5 truncate font-mono text-xs text-slate-500">
|
||||
{d.id}
|
||||
</p>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
441
apps/frontend/src/pages/DeviceDetailPage.tsx
Normal file
441
apps/frontend/src/pages/DeviceDetailPage.tsx
Normal file
@ -0,0 +1,441 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
534
apps/frontend/src/pages/SetupRobotPage.tsx
Normal file
534
apps/frontend/src/pages/SetupRobotPage.tsx
Normal file
@ -0,0 +1,534 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user