tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/portal/frontend/src/components/ui/tabs.tsx b/portal/frontend/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..6dbd649
--- /dev/null
+++ b/portal/frontend/src/components/ui/tabs.tsx
@@ -0,0 +1,88 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+import { Tabs as TabsPrimitive } from "radix-ui"
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/portal/frontend/src/index.css b/portal/frontend/src/index.css
new file mode 100644
index 0000000..2b6549c
--- /dev/null
+++ b/portal/frontend/src/index.css
@@ -0,0 +1,113 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --font-heading: var(--font-sans);
+ --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-mono: ui-monospace, "Fira Code", Consolas, monospace;
+
+ --color-background: #0b0f17;
+ --color-foreground: #e6edf7;
+ --color-card: #141b2a;
+ --color-card-foreground: #e6edf7;
+ --color-popover: #141b2a;
+ --color-popover-foreground: #e6edf7;
+ --color-primary: #38bdf8;
+ --color-primary-foreground: #020617;
+ --color-secondary: #1e2740;
+ --color-secondary-foreground: #e6edf7;
+ --color-muted: #1e2740;
+ --color-muted-foreground: #94a3b8;
+ --color-accent: #818cf8;
+ --color-accent-foreground: #ffffff;
+ --color-destructive: #ef4444;
+ --color-border: #283350;
+ --color-input: #283350;
+ --color-ring: #38bdf8;
+
+ --radius-sm: 0.375rem;
+ --radius-md: 0.5rem;
+ --radius-lg: 0.625rem;
+ --radius-xl: 0.875rem;
+}
+
+:root {
+ color-scheme: dark;
+ --background: #0b0f17;
+ --foreground: #e6edf7;
+ --card: #141b2a;
+ --card-foreground: #e6edf7;
+ --popover: #141b2a;
+ --popover-foreground: #e6edf7;
+ --primary: #38bdf8;
+ --primary-foreground: #020617;
+ --secondary: #1e2740;
+ --secondary-foreground: #e6edf7;
+ --muted: #1e2740;
+ --muted-foreground: #94a3b8;
+ --accent: #818cf8;
+ --accent-foreground: #ffffff;
+ --destructive: #ef4444;
+ --border: #283350;
+ --input: #283350;
+ --ring: #38bdf8;
+ --radius: 0.625rem;
+}
+
+.dark {
+ --background: #0b0f17;
+ --foreground: #e6edf7;
+ --card: #141b2a;
+ --card-foreground: #e6edf7;
+ --popover: #141b2a;
+ --popover-foreground: #e6edf7;
+ --primary: #38bdf8;
+ --primary-foreground: #020617;
+ --secondary: #1e2740;
+ --secondary-foreground: #e6edf7;
+ --muted: #1e2740;
+ --muted-foreground: #94a3b8;
+ --accent: #818cf8;
+ --accent-foreground: #ffffff;
+ --destructive: #ef4444;
+ --border: #283350;
+ --input: #283350;
+ --ring: #38bdf8;
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ font-family: var(--font-sans);
+ margin: 0;
+ min-height: 100vh;
+ }
+ #root {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #141b2a;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #283350;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #475569;
+}
diff --git a/portal/frontend/src/lib/api.ts b/portal/frontend/src/lib/api.ts
new file mode 100644
index 0000000..b48edf8
--- /dev/null
+++ b/portal/frontend/src/lib/api.ts
@@ -0,0 +1,52 @@
+import type { HealthResponse, InstallRequest, InstallResponse, Installation } from '@/types';
+
+const API_BASE = '';
+
+async function fetchJson(path: string, init?: RequestInit): Promise {
+ const res = await fetch(`${API_BASE}${path}`, {
+ headers: { 'Content-Type': 'application/json' },
+ ...init,
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ throw new Error(data.error || `Error ${res.status}`);
+ }
+ return data as T;
+}
+
+export const api = {
+ health: () => fetchJson('/api/health'),
+
+ install: (body: InstallRequest) =>
+ fetchJson('/api/install', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }),
+
+ installations: () => fetchJson('/api/installations'),
+
+ complete: (token: string, status: Installation['status'], hostname?: string) =>
+ fetchJson<{ success: boolean }>('/api/complete', {
+ method: 'POST',
+ body: JSON.stringify({ token, status, hostname }),
+ }),
+
+ updateStatus: (token: string, status: Installation['status'], adminToken: string) =>
+ fetchJson<{ success: boolean }>(`/api/admin/installations/${token}/status`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${adminToken}` },
+ body: JSON.stringify({ status }),
+ }),
+
+ delete: (token: string, adminToken: string) =>
+ fetchJson<{ success: boolean }>(`/api/admin/installations/${token}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${adminToken}` },
+ }),
+
+ reset: (token: string, adminToken: string) =>
+ fetchJson<{ success: boolean }>(`/api/admin/installations/${token}/reset`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${adminToken}` },
+ }),
+};
diff --git a/portal/frontend/src/lib/utils.ts b/portal/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/portal/frontend/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/portal/frontend/src/main.tsx b/portal/frontend/src/main.tsx
new file mode 100644
index 0000000..bef5202
--- /dev/null
+++ b/portal/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App.tsx'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/portal/frontend/src/pages/AdminPage.tsx b/portal/frontend/src/pages/AdminPage.tsx
new file mode 100644
index 0000000..79102a1
--- /dev/null
+++ b/portal/frontend/src/pages/AdminPage.tsx
@@ -0,0 +1,284 @@
+import { useEffect, useMemo, useState } from 'react';
+import { api } from '@/lib/api';
+import type { Installation } from '@/types';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Label } from '@/components/ui/label';
+import { Lock, MoreHorizontal, RefreshCw, Search, Trash2 } from 'lucide-react';
+
+const statusColors: Record = {
+ pending: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20',
+ downloaded: 'bg-violet-500/10 text-violet-400 border-violet-500/20',
+ completed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
+ failed: 'bg-red-500/10 text-red-400 border-red-500/20',
+};
+
+export function AdminPage() {
+ const [token, setToken] = useState(localStorage.getItem('neubat-admin-token') || '');
+ const [loggedIn, setLoggedIn] = useState(false);
+ const [installations, setInstallations] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [search, setSearch] = useState('');
+ const [statusFilter, setStatusFilter] = useState('');
+ const [error, setError] = useState(null);
+
+ async function login() {
+ localStorage.setItem('neubat-admin-token', token);
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await api.installations();
+ setInstallations(data);
+ setLoggedIn(true);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Error de autenticación');
+ setLoggedIn(false);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function refresh() {
+ setLoading(true);
+ try {
+ const data = await api.installations();
+ setInstallations(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Error cargando datos');
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function updateStatus(t: string, status: Installation['status']) {
+ try {
+ await api.updateStatus(t, status, token);
+ refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Error actualizando estado');
+ }
+ }
+
+ async function reset(t: string) {
+ try {
+ await api.reset(t, token);
+ refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Error reseteando');
+ }
+ }
+
+ async function remove(t: string) {
+ if (!confirm(`¿Eliminar instalación ${t.slice(0, 8)}…?`)) return;
+ try {
+ await api.delete(t, token);
+ refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Error eliminando');
+ }
+ }
+
+ useEffect(() => {
+ if (loggedIn) refresh();
+ }, [loggedIn]);
+
+ const filtered = useMemo(() => {
+ return installations
+ .filter((i) => (statusFilter ? i.status === statusFilter : true))
+ .filter(
+ (i) =>
+ i.token.toLowerCase().includes(search.toLowerCase()) ||
+ (i.hostname || '').toLowerCase().includes(search.toLowerCase()) ||
+ i.profile.toLowerCase().includes(search.toLowerCase())
+ )
+ .slice()
+ .reverse();
+ }, [installations, search, statusFilter]);
+
+ if (!loggedIn) {
+ return (
+
+
+
+
+
+ Acceso al panel
+
+ Introduce el token de administrador configurado en el servidor.
+
+
+
+
+ setToken(e.target.value)}
+ placeholder="ADMIN_TOKEN"
+ onKeyDown={(e) => e.key === 'Enter' && login()}
+ />
+
+
+ {error && {error} }
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Panel de administración
+ Gestión de instalaciones y estados.
+
+
+
+
+ {error && (
+ {error}
+ )}
+
+
+
+
+
+
+ setSearch(e.target.value)}
+ className="pl-9"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ Token
+ Perfil
+ Hostname
+ Estado
+ Creada
+ Acciones
+
+
+
+ {loading ? (
+
+
+
+
+
+ ) : filtered.length === 0 ? (
+
+
+ Sin resultados
+
+
+ ) : (
+ filtered.map((i) => (
+
+
+ {i.token.slice(0, 12)}…
+
+ {i.profile}
+ {i.hostname || '-'}
+
+
+ {i.status}
+
+
+
+ {new Date(i.created_at).toLocaleString('es-ES')}
+
+
+
+
+
+
+
+ updateStatus(i.token, 'pending')}>
+ Marcar pending
+
+ updateStatus(i.token, 'completed')}>
+ Marcar completed
+
+ updateStatus(i.token, 'failed')}>
+ Marcar failed
+
+ reset(i.token)}>
+
+ Resetear
+
+ remove(i.token)}
+ className="text-red-400 focus:text-red-400"
+ >
+
+ Eliminar
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/portal/frontend/src/pages/HomePage.test.tsx b/portal/frontend/src/pages/HomePage.test.tsx
new file mode 100644
index 0000000..758084e
--- /dev/null
+++ b/portal/frontend/src/pages/HomePage.test.tsx
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { BrowserRouter } from 'react-router-dom';
+import { HomePage } from './HomePage';
+
+function Wrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+describe('HomePage', () => {
+ beforeEach(() => {
+ globalThis.fetch = vi.fn();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renderiza el formulario y la lista de instalaciones', async () => {
+ (globalThis.fetch as any).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [
+ { token: 'abc123', profile: 'base', status: 'pending', created_at: new Date().toISOString() },
+ ],
+ });
+
+ render(, { wrapper: Wrapper });
+
+ expect(screen.getByRole('button', { name: /Generar instalación/i })).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(screen.getByText('base')).toBeInTheDocument();
+ });
+ });
+
+ it('crea una instalación y muestra resultados', async () => {
+ (globalThis.fetch as any)
+ .mockResolvedValueOnce({ ok: true, json: async () => [] })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ success: true,
+ token: 'tokentest',
+ machine_id: 'machine1',
+ config_url: '/api/config/tokentest',
+ boot_url: '/boot/tokentest',
+ message: 'Creada',
+ }),
+ })
+ .mockResolvedValueOnce({ ok: true, json: async () => [] });
+
+ render(, { wrapper: Wrapper });
+
+ const button = screen.getByRole('button', { name: /Generar instalación/i });
+ await userEvent.click(button);
+
+ await waitFor(() => {
+ expect(screen.getByText('URL de arranque iPXE')).toBeInTheDocument();
+ expect(screen.getByText(/boot\/tokentest/i)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/portal/frontend/src/pages/HomePage.tsx b/portal/frontend/src/pages/HomePage.tsx
new file mode 100644
index 0000000..fd7aa3f
--- /dev/null
+++ b/portal/frontend/src/pages/HomePage.tsx
@@ -0,0 +1,226 @@
+import { useEffect, useState } from 'react';
+import { api } from '@/lib/api';
+import type { Installation, InstallRequest, InstallResponse } from '@/types';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import { CheckCircle, Copy, Server, Terminal, Wifi } from 'lucide-react';
+
+const statusColors: Record = {
+ pending: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20',
+ downloaded: 'bg-violet-500/10 text-violet-400 border-violet-500/20',
+ completed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
+ failed: 'bg-red-500/10 text-red-400 border-red-500/20',
+};
+
+export function HomePage() {
+ const [installations, setInstallations] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadInstallations();
+ }, []);
+
+ async function loadInstallations() {
+ try {
+ const data = await api.installations();
+ setInstallations(data.slice().reverse());
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setSubmitting(true);
+ setError(null);
+ setResult(null);
+
+ const form = new FormData(e.currentTarget);
+ const body: InstallRequest = {
+ profile: form.get('profile') as string,
+ hostname: (form.get('hostname') as string) || undefined,
+ username: (form.get('username') as string) || undefined,
+ packages: (form.get('packages') as string)
+ .split(/\s+/)
+ .filter(Boolean),
+ };
+
+ try {
+ const data = await api.install(body);
+ setResult(data);
+ loadInstallations();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Error desconocido');
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ function copy(text: string) {
+ navigator.clipboard.writeText(text);
+ }
+
+ const base = window.location.origin;
+
+ return (
+
+
+
+ Nueva instalación
+
+ Configura el sistema, obtén tu URL única y arranca por iPXE. Sin USB, sin intervención.
+
+
+
+
+
+
+
+ Formulario de despliegue
+
+ Elige perfil, hostname opcional y paquetes extra.
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {result && (
+
+
+
+ Instalación creada
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+function CopyField({ label, value, onCopy }: { label: string; value: string; onCopy: (v: string) => void }) {
+ return (
+
+
+
+ {value}
+
+
+
+ );
+}
diff --git a/portal/frontend/src/test/setup.ts b/portal/frontend/src/test/setup.ts
new file mode 100644
index 0000000..bb02c60
--- /dev/null
+++ b/portal/frontend/src/test/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest';
diff --git a/portal/frontend/src/types.ts b/portal/frontend/src/types.ts
new file mode 100644
index 0000000..cf8c68b
--- /dev/null
+++ b/portal/frontend/src/types.ts
@@ -0,0 +1,34 @@
+export interface Installation {
+ token: string;
+ machine_id?: string;
+ profile: string;
+ status: 'pending' | 'downloaded' | 'completed' | 'failed';
+ created_at: string;
+ downloaded_at?: string;
+ completed_at?: string;
+ hostname?: string;
+ error?: string;
+}
+
+export interface InstallRequest {
+ profile: string;
+ hostname?: string;
+ username?: string;
+ desktop?: string;
+ packages?: string[];
+}
+
+export interface InstallResponse {
+ success: boolean;
+ token: string;
+ machine_id: string;
+ config_url: string;
+ boot_url: string;
+ message: string;
+}
+
+export interface HealthResponse {
+ status: string;
+ version: string;
+ timestamp: string;
+}
diff --git a/portal/frontend/tsconfig.app.json b/portal/frontend/tsconfig.app.json
new file mode 100644
index 0000000..9c99662
--- /dev/null
+++ b/portal/frontend/tsconfig.app.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "esnext",
+ "types": ["vite/client"],
+ "allowArbitraryExtensions": true,
+ "skipLibCheck": true,
+
+ "ignoreDeprecations": "6.0",
+
+ /* Bundler mode */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/portal/frontend/tsconfig.json b/portal/frontend/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/portal/frontend/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/portal/frontend/tsconfig.node.json b/portal/frontend/tsconfig.node.json
new file mode 100644
index 0000000..8455dcb
--- /dev/null
+++ b/portal/frontend/tsconfig.node.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "es2023",
+ "lib": ["ES2023"],
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "module": "nodenext",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/portal/frontend/vite.config.ts b/portal/frontend/vite.config.ts
new file mode 100644
index 0000000..64009a7
--- /dev/null
+++ b/portal/frontend/vite.config.ts
@@ -0,0 +1,19 @@
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import path from 'node:path'
+import { defineConfig } from 'vite'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ base: '/',
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ build: {
+ outDir: '../public',
+ emptyOutDir: false,
+ },
+})
diff --git a/portal/frontend/vitest.config.ts b/portal/frontend/vitest.config.ts
new file mode 100644
index 0000000..7cf16c7
--- /dev/null
+++ b/portal/frontend/vitest.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+import path from 'node:path';
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/test/setup.ts'],
+ },
+});
diff --git a/portal/public/admin.html b/portal/public/admin.html
deleted file mode 100644
index ed9b82d..0000000
--- a/portal/public/admin.html
+++ /dev/null
@@ -1,218 +0,0 @@
-
-
-
-
-
-NEUBAT — Panel de administración
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | Token |
- Perfil |
- Hostname |
- Estado |
- Creada |
- Acciones |
-
-
- | Cargando… |
-
-
-
-
-
-
-
-
-
-
diff --git a/portal/public/index.html b/portal/public/index.html
deleted file mode 100644
index 165063b..0000000
--- a/portal/public/index.html
+++ /dev/null
@@ -1,218 +0,0 @@
-
-
-
-
-
-NEUBAT — Instalación desatendida de Arch Linux
-
-
-
-
-
- Instalación desatendida de Arch Linux por red. Configura tu sistema, obtén tu URL única y arranca por iPXE. Sin USB, sin intervención.
-
-
-
-
-
-
- Instalación creada
-
-
-
-
-
-
-
-
-
-
- Instalaciones recientes
-
- | Token | Perfil | Estado | Creada |
- | Cargando… |
-
-
-
-
-
-
-
-
-
diff --git a/portal/server.js b/portal/server.js
index ce7e750..620b192 100644
--- a/portal/server.js
+++ b/portal/server.js
@@ -45,17 +45,12 @@ app.use('/boot', install.bootRouter);
app.use(express.static(path.join(__dirname, 'public')));
-// Panel de administración en /admin
-app.get('/admin', (req, res) => {
- res.sendFile(path.join(__dirname, 'public', 'admin.html'));
-});
-
// 404 JSON para rutas API no definidas
app.use('/api', (req, res) => {
res.status(404).json({ error: 'Ruta no encontrada' });
});
-// Fallback SPA
+// Fallback SPA (React app)
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
diff --git a/portal/tests/app.test.js b/portal/tests/app.test.js
index cf0afe1..1ae28bc 100644
--- a/portal/tests/app.test.js
+++ b/portal/tests/app.test.js
@@ -43,9 +43,9 @@ describe('app integration', () => {
expect(res.text).toContain('NEUBAT');
});
- test('/admin sirve admin.html', async () => {
+ test('/admin sirve la SPA React', async () => {
const res = await request(app).get('/admin').expect(200);
- expect(res.text).toContain('Admin');
+ expect(res.text).toContain('');
});
test('API 404 devuelve JSON', async () => {
|