feat: Initialize Next.js project with Prisma, Tailwind CSS, and API routes for device management and testing.

This commit is contained in:
2025-12-22 22:21:27 -05:00
commit 2e415d1897
33 changed files with 8222 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
---
description: HDMI Tester CLI Simulator Workflow
---
This workflow explains how to use the `simulator.py` tool to test the HDMI Head Unit logic without physical hardware.
### Prerequisites
1. Ensure the Web UI is running:
```bash
npm run dev
```
2. (Optional) Create a virtual environment and install requirements:
```bash
pip install requests
```
### 1. Register a New Device
// turbo
Run this command to send the initial registration request to the server:
```bash
python3 firmware/simulator.py register
```
After running this, go to the [Admin Panel](http://localhost:3000/admin) to enroll the device.
### 2. Send a Mock Test Result
// turbo
Once the device is enrolled, you can send a mock HDMI test:
```bash
python3 firmware/simulator.py test
```
### 3. Switch Modes & Slots
// turbo
Toggle between DEV and PROD modes, or swap A/B slots:
```bash
python3 firmware/simulator.py mode-prod
python3 firmware/simulator.py swap
```
### 4. Continuous Simulation Loop
// turbo
To keep the device alive and reporting periodically:
```bash
python3 firmware/simulator.py loop
```

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

BIN
dev.db Normal file

Binary file not shown.

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,6 @@
{
"serial_number": "CB1FCDC3",
"mode": "DEV",
"active_slot": "A",
"status": "ENROLLED"
}

138
firmware/simulator.py Normal file
View File

@@ -0,0 +1,138 @@
import json
import os
import time
import requests
import uuid
import socket
class HeadUnit:
def __init__(self, server_url="http://localhost:3000", config_path="firmware/config/device.json"):
self.server_url = server_url
self.config_path = config_path
self.config = self.load_config()
self.mac_address = self.get_mac()
self.serial_number = self.config.get("serial_number", str(uuid.uuid4())[:8].upper())
self.mode = self.config.get("mode", "DEV") # DEV or PROD
self.active_slot = self.config.get("active_slot", "A")
self.status = self.config.get("status", "PENDING")
self.save_config()
def get_mac(self):
# Simulator MAC
return ":".join(["%02x" % (uuid.getnode() >> ele & 0xff) for ele in range(0, 8*6, 8)][::-1])
def load_config(self):
if os.path.exists(self.config_path):
with open(self.config_path, 'r') as f:
return json.load(f)
return {}
def save_config(self):
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
self.config = {
"serial_number": self.serial_number,
"mode": self.mode,
"active_slot": self.active_slot,
"status": self.status
}
with open(self.config_path, 'w') as f:
json.dump(self.config, f, indent=4)
def register(self):
print(f"[*] Registering device {self.serial_number} ({self.mac_address})...")
try:
resp = requests.post(f"{self.server_url}/api/devices/register", json={
"serialNumber": self.serial_number,
"macAddress": self.mac_address
})
if resp.status_code == 200:
data = resp.json()
self.status = data.get("status")
self.save_config()
print(f"[+] Registration successful. Status: {self.status}")
return True
else:
print(f"[-] Registration failed: {resp.text}")
except Exception as e:
print(f"[-] Connection error: {e}")
return False
def send_heartbeat(self):
# We'll use the report endpoint for heartbeats since it updates lastHeartbeat
pass
def report_test(self, test_data):
if self.status != "ENROLLED":
print("[-] Device not enrolled. Cannot report tests.")
return
print(f"[*] Reporting test result from Slot {self.active_slot} ({self.mode} mode)...")
payload = {
"serialNumber": self.serial_number,
**test_data
}
try:
resp = requests.post(f"{self.server_url}/api/tests/report", json=payload)
if resp.status_code == 200:
print("[+] Test reported successfully.")
else:
print(f"[-] Test report failed: {resp.text}")
except Exception as e:
print(f"[-] Connection error: {e}")
def switch_mode(self, new_mode):
self.mode = new_mode
self.save_config()
print(f"[*] Switched to {self.mode} mode.")
def switch_slot(self):
self.active_slot = "B" if self.active_slot == "A" else "A"
self.save_config()
print(f"[*] Switched to Slot {self.active_slot}.")
def generate_mock_hdmi_test():
return {
"type": "HDMI_OFFLINE",
"status": "PASS",
"hdmi5v": False,
"summary": "Offline diode check completed. All lines within tolerance.",
"diodeResults": {
"DDC_SCL": 0.542,
"DDC_SDA": 0.544,
"CEC": 0.612,
"HPD": 0.589,
"TMDS_CLK+": 0.498,
"TMDS_CLK-": 0.497
}
}
if __name__ == "__main__":
import sys
hu = HeadUnit()
if len(sys.argv) < 2:
print("Usage: python simulator.py [register|test|mode-dev|mode-prod|swap|loop]")
sys.exit(1)
cmd = sys.argv[1]
if cmd == "register":
hu.register()
elif cmd == "test":
hu.report_test(generate_mock_hdmi_test())
elif cmd == "mode-dev":
hu.switch_mode("DEV")
elif cmd == "mode-prod":
hu.switch_mode("PROD")
elif cmd == "swap":
hu.switch_slot()
elif cmd == "loop":
print("[*] Starting simulator loop. Ctrl+C to exit.")
while True:
hu.register()
if hu.status == "ENROLLED":
hu.report_test(generate_mock_hdmi_test())
time.sleep(10)
else:
print("Unknown command.")

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6971
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "hdmi-tester",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@prisma/client": "^6.19.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"prisma": "^6.19.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

BIN
prisma/dev.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL PRIMARY KEY,
"serialNumber" TEXT NOT NULL,
"macAddress" TEXT NOT NULL,
"name" TEXT DEFAULT 'Unnamed Device',
"status" TEXT NOT NULL DEFAULT 'PENDING',
"lastHeartbeat" DATETIME,
"uptime" INTEGER,
"temperature" REAL,
"activeSlot" TEXT NOT NULL DEFAULT 'A',
"firmwareVersion" TEXT NOT NULL DEFAULT '1.0.0',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Test" (
"id" TEXT NOT NULL PRIMARY KEY,
"deviceId" TEXT NOT NULL,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL,
"hdmi5v" BOOLEAN NOT NULL,
"edid1080p" BOOLEAN,
"edid4k120" BOOLEAN,
"diodeResults" TEXT,
"rawOutput" TEXT,
"summary" TEXT NOT NULL,
CONSTRAINT "Test_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Device_serialNumber_key" ON "Device"("serialNumber");
-- CreateIndex
CREATE UNIQUE INDEX "Device_macAddress_key" ON "Device"("macAddress");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

42
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,42 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Device {
id String @id @default(cuid())
serialNumber String @unique
macAddress String @unique
name String? @default("Unnamed Device")
status String @default("PENDING") // PENDING, ENROLLED, OFFLINE
lastHeartbeat DateTime?
uptime Int? // in seconds
temperature Float?
activeSlot String @default("A") // A or B
firmwareVersion String @default("1.0.0")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tests Test[]
}
model Test {
id String @id @default(cuid())
deviceId String
device Device @relation(fields: [deviceId], references: [id])
timestamp DateTime @default(now())
type String // HDMI_OFFLINE, HDMI_ONLINE
status String // PASS, FAIL, WARNING
hdmi5v Boolean
edid1080p Boolean?
edid4k120 Boolean?
diodeResults String? // JSON string of pin-by-pin results
rawOutput String? // Any extra log data from the device
summary String // Executive summary of the result
}

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

152
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,152 @@
"use client";
import { useState, useEffect } from "react";
import { Shield, Smartphone, ArrowRight, Settings2, PlusCircle, CheckCircle2, XCircle, Clock } from "lucide-react";
interface Device {
id: string;
serialNumber: string;
macAddress: string;
name: string;
status: string;
firmwareVersion: string;
activeSlot: string;
}
export default function AdminPage() {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/devices")
.then(res => res.json())
.then(data => {
setDevices(data);
setLoading(false);
});
}, []);
const handleEnroll = async (id: string) => {
const res = await fetch(`/api/devices/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "ENROLLED" }),
});
if (res.ok) {
setDevices(devices.map(d => d.id === id ? { ...d, status: "ENROLLED" } : d));
}
};
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2 flex items-center gap-3">
<Shield className="w-8 h-8 text-sky-500" />
Command Center
</h1>
<p className="text-slate-400">Manage device fleet, enrollment, and firmware orchestration.</p>
</div>
<button className="bg-sky-500 hover:bg-sky-400 text-white px-4 py-2 rounded-xl text-sm font-bold flex items-center gap-2 transition-all shadow-[0_0_20px_rgba(56,189,248,0.2)]">
<PlusCircle className="w-4 h-4" />
Proactive Provisioning
</button>
</div>
<div className="grid grid-cols-1 gap-6">
{/* Device Management Table */}
<div className="bg-white/[0.02] border border-white/5 rounded-3xl overflow-hidden backdrop-blur-md">
<div className="px-6 py-4 border-b border-white/5 bg-white/[0.01] flex items-center justify-between">
<h2 className="text-xs font-bold uppercase tracking-widest text-slate-500 italic">Connected Hardware Tree</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="border-b border-white/5 text-[10px] font-bold uppercase tracking-widest text-slate-600">
<th className="px-6 py-4">Hardware ID</th>
<th className="px-6 py-4">Designation</th>
<th className="px-6 py-4">Architecture</th>
<th className="px-6 py-4">Orchestration</th>
<th className="px-6 py-4">Deployment</th>
<th className="px-6 py-4 text-right">Control</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.02]">
{loading ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-500">Scanning network for head units...</td>
</tr>
) : devices.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-500">No devices detected in local mesh.</td>
</tr>
) : (
devices.map((device) => (
<tr key={device.id} className="hover:bg-white/[0.01] transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${device.status === 'ENROLLED' ? 'bg-sky-400' : 'bg-amber-400 animate-pulse'}`} />
<div className="flex flex-col">
<span className="font-mono text-[10px] text-slate-400">{device.macAddress}</span>
<span className="text-xs font-bold text-slate-200">{device.serialNumber}</span>
</div>
</div>
</td>
<td className="px-6 py-4">
<input
type="text"
defaultValue={device.name}
className="bg-transparent border-b border-white/5 text-xs focus:border-sky-500 outline-none w-full pb-1 transition-colors"
placeholder="Assign static name..."
/>
</td>
<td className="px-6 py-4 text-[10px] font-mono text-slate-500 uppercase">
RPi Zero 2 W
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-1.5">
<Settings2 className="w-3.5 h-3.5 text-slate-500" />
<span className="text-[10px] font-bold text-slate-400 bg-white/5 py-0.5 px-2 rounded uppercase">
Slot {device.activeSlot} (v{device.firmwareVersion})
</span>
</div>
</td>
<td className="px-6 py-4">
{device.status === "ENROLLED" ? (
<span className="text-[10px] font-black text-emerald-500/80 tracking-tighter uppercase flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Enrolled
</span>
) : (
<span className="text-[10px] font-black text-amber-500/80 tracking-tighter uppercase flex items-center gap-1">
<Clock className="w-3 h-3" /> Awaiting
</span>
)}
</td>
<td className="px-6 py-4 text-right">
{device.status === "PENDING" ? (
<button
onClick={() => handleEnroll(device.id)}
className="bg-sky-500/10 hover:bg-sky-500/20 text-sky-400 text-[10px] font-black py-1.5 px-3 rounded uppercase transition-colors flex items-center gap-2 ml-auto"
>
Enroll <ArrowRight className="w-3 h-3" />
</button>
) : (
<button
className="text-slate-600 hover:text-rose-500 transition-colors"
title="Decommission Hardware"
>
<XCircle className="w-4 h-4" />
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const [deviceCount, testCount, onlineCount, recentTests] = await Promise.all([
prisma.device.count(),
prisma.test.count(),
prisma.device.count({ where: { status: "ENROLLED" } }),
prisma.test.findMany({
take: 10,
orderBy: { timestamp: "desc" },
include: { device: true },
}),
]);
return NextResponse.json({
stats: { deviceCount, testCount, onlineCount },
recentTests,
});
} catch (error) {
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const { status, name } = await req.json();
const device = await prisma.device.update({
where: { id },
data: {
status: status || undefined,
name: name || undefined
},
});
return NextResponse.json(device);
} catch (error) {
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
try {
const { serialNumber, macAddress } = await req.json();
if (!serialNumber || !macAddress) {
return NextResponse.json(
{ error: "Serial number and MAC address are required" },
{ status: 400 }
);
}
// Check if device already exists
let device = await prisma.device.findFirst({
where: {
OR: [
{ serialNumber },
{ macAddress }
]
}
});
if (!device) {
// Register as pending
device = await prisma.device.create({
data: {
serialNumber,
macAddress,
status: "PENDING",
},
});
}
return NextResponse.json(device);
} catch (error) {
console.error("Registration error:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const devices = await prisma.device.findMany({
orderBy: { createdAt: "desc" },
});
return NextResponse.json(devices);
} catch (error) {
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
try {
const {
serialNumber,
type,
status,
hdmi5v,
edid1080p,
edid4k120,
diodeResults,
rawOutput,
summary
} = await req.json();
const device = await prisma.device.findUnique({
where: { serialNumber }
});
if (!device) {
return NextResponse.json({ error: "Device not found" }, { status: 404 });
}
if (device.status !== "ENROLLED") {
return NextResponse.json({ error: "Device not enrolled" }, { status: 403 });
}
const test = await prisma.test.create({
data: {
deviceId: device.id,
type,
status,
hdmi5v,
edid1080p,
edid4k120,
diodeResults: diodeResults ? JSON.stringify(diodeResults) : null,
rawOutput,
summary,
},
});
// Update last heartbeat
await prisma.device.update({
where: { id: device.id },
data: { lastHeartbeat: new Date() }
});
return NextResponse.json(test);
} catch (error) {
console.error("Test reporting error:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
src/app/globals.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

55
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "HDMI Tester | Enterprise Dashboard",
description: "Advanced HDMI testing and modular device management",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<body className={`${inter.className} bg-[#0a0a0c] text-slate-200 min-h-screen antialiased`} suppressHydrationWarning>
<div className="fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,_rgba(56,189,248,0.08),transparent_50%)] pointer-events-none" />
<div className="relative flex flex-col min-h-screen">
<nav className="border-b border-white/5 bg-[#0a0a0c]/80 backdrop-blur-xl sticky top-0 z-50">
<div className="container mx-auto px-6 h-16 flex items-center justify-between">
<div className="flex items-center gap-2 group cursor-pointer">
<div className="w-8 h-8 bg-sky-500 rounded-lg flex items-center justify-center group-hover:shadow-[0_0_20px_rgba(56,189,248,0.4)] transition-all duration-300">
<span className="text-white font-bold text-lg">H</span>
</div>
<span className="text-xl font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/60">
HDMI<span className="text-sky-400">Tester</span>
</span>
</div>
<div className="flex items-center gap-8">
<a href="/" className="text-sm font-medium text-slate-400 hover:text-white transition-colors">Dashboard</a>
<a href="/admin" className="text-sm font-medium text-slate-400 hover:text-white transition-colors">Admin</a>
<div className="h-4 w-px bg-white/10" />
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<span className="text-xs font-semibold uppercase tracking-widest text-slate-500">Live</span>
</div>
</div>
</div>
</nav>
<main className="flex-1 container mx-auto px-6 py-10">
{children}
</main>
<footer className="border-t border-white/5 py-8 mt-auto">
<div className="container mx-auto px-6 text-center text-slate-500 text-xs">
&copy; 2025 HDMI Tester Enterprise. All rights reserved.
</div>
</footer>
</div>
</body>
</html>
);
}

257
src/app/page.tsx Normal file
View File

@@ -0,0 +1,257 @@
"use client";
import { useState, useEffect } from "react";
import { Activity, Cpu, CheckCircle2, AlertCircle, Clock, Zap } from "lucide-react";
import Link from "next/link";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
interface Device {
id: string;
name: string | null;
serialNumber: string;
status: string;
}
interface Test {
id: string;
type: string;
status: string;
summary: string;
timestamp: string;
device: Device;
}
interface DashboardData {
stats: {
deviceCount: number;
testCount: number;
onlineCount: number;
};
recentTests: Test[];
}
export default function DashboardPage() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
const [isUpdating, setIsUpdating] = useState(false);
const fetchData = async () => {
try {
setIsUpdating(true);
const res = await fetch("/api/dashboard/stats");
const newData = await res.json();
setData(newData);
setLastUpdate(new Date());
setLoading(false);
setTimeout(() => setIsUpdating(false), 1000);
} catch (error) {
console.error("Failed to fetch dashboard data:", error);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 3000); // Poll every 3 seconds
return () => clearInterval(interval);
}, []);
if (loading || !data) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-sky-500/20 border-t-sky-500 rounded-full animate-spin" />
<p className="text-slate-500 font-mono text-xs uppercase tracking-widest animate-pulse">Syncing with hardware mesh...</p>
</div>
</div>
);
}
return (
<div className="space-y-10">
{/* Header with Live Indicator */}
<div className="flex items-center justify-between">
<h1 className="text-sm font-black uppercase tracking-[0.3em] text-slate-500 flex items-center gap-3">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-sky-500"></span>
</span>
Real-time Telemetry
</h1>
<div className="text-[10px] font-mono text-slate-600 uppercase">
Last sync: {lastUpdate.toLocaleTimeString()}
</div>
</div>
{/* Hero Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ label: "Active Nodes", value: data.stats.onlineCount, icon: Activity, color: "text-sky-400", glow: "shadow-sky-500/10" },
{ label: "Tests Conducted", value: data.stats.testCount, icon: Zap, color: "text-amber-400", glow: "shadow-amber-500/10" },
{ label: "Registered Hardware", value: data.stats.deviceCount, icon: Cpu, color: "text-purple-400", glow: "shadow-purple-500/10" },
].map((stat, i) => (
<div
key={i}
className={cn(
"bg-white/[0.03] border border-white/5 rounded-2xl p-6 backdrop-blur-sm relative overflow-hidden group transition-all duration-700",
isUpdating && "scale-[1.01] bg-white/[0.05] border-white/10",
stat.glow
)}
>
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.02] to-transparent pointer-events-none" />
<div className="flex items-center justify-between relative z-10">
<div>
<p className="text-slate-500 text-xs font-bold uppercase tracking-widest mb-1">{stat.label}</p>
<h3 className={cn(
"text-4xl font-bold tracking-tight transition-all duration-500",
isUpdating ? "scale-110 " + stat.color : "text-white"
)}>
{stat.value}
</h3>
</div>
<stat.icon className={cn(
"w-10 h-10 transition-all duration-700",
isUpdating ? "opacity-100 scale-110" : "opacity-20",
stat.color
)} />
</div>
</div>
))}
</div>
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Recent Activity Feed */}
<div className="lg:col-span-2 space-y-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-xl font-bold flex items-center gap-2">
<Clock className="w-5 h-5 text-sky-400" />
Latest Test Sequences
</h2>
</div>
<div className="bg-white/[0.02] border border-white/5 rounded-3xl overflow-hidden backdrop-blur-md">
<table className="w-full text-left">
<thead>
<tr className="border-b border-white/5 bg-white/[0.01]">
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-slate-500">Source</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-slate-500">Operation</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-slate-500">Status</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-slate-500">Metric</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-slate-500">Execution</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-slate-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.02]">
{data.recentTests.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-500 italic">No telemetry data reported yet.</td>
</tr>
) : (
data.recentTests.map((test) => (
<tr key={test.id} className="hover:bg-white/[0.02] transition-colors group animate-in fade-in slide-in-from-left-2 duration-500">
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="font-semibold text-slate-200">{test.device.name || "Head Unit"}</span>
<span className="text-[10px] text-slate-500 font-mono">{test.device.serialNumber}</span>
</div>
</td>
<td className="px-6 py-4">
<span className="text-sm px-2 py-1 rounded-md bg-white/5 border border-white/5 text-slate-300 font-medium whitespace-nowrap">
{test.type.replace("_", " ")}
</span>
</td>
<td className="px-6 py-4">
{test.status === "PASS" ? (
<div className="flex items-center gap-1.5 text-emerald-400 text-sm font-bold">
<CheckCircle2 className="w-4 h-4" />
SUCCESS
</div>
) : (
<div className="flex items-center gap-1.5 text-rose-400 text-sm font-bold">
<AlertCircle className="w-4 h-4" />
FAILURE
</div>
)}
</td>
<td className="px-6 py-4">
<span className="text-xs text-slate-400 max-w-[200px] truncate block">{test.summary}</span>
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-[10px] font-bold text-slate-400 uppercase">
{new Date(test.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="text-[9px] text-slate-600 font-medium">
{new Date(test.timestamp).toLocaleDateString([], { month: 'short', day: 'numeric' })}
</span>
</div>
</td>
<td className="px-6 py-4 text-right">
<Link
href={`/tests/${test.id}`}
className="text-xs font-bold text-sky-400 hover:text-sky-300 underline-offset-4 hover:underline"
>
INSIGHTS
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
<div className="bg-sky-500/10 border border-sky-500/20 rounded-2xl p-6 relative overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-10">
<Zap className="w-16 h-16 text-sky-400" />
</div>
<h4 className="font-bold text-sky-400 mb-2 flex items-center gap-2 relative z-10">
<Zap className="w-4 h-4" />
Intelligence Engine
</h4>
<p className="text-xs text-slate-400 leading-relaxed mb-4 relative z-10">
Real-time spectral analysis of HDMI bus metrics. All processing orchestrated in the cloud with sub-ms edge latency.
</p>
<div className="flex items-center gap-2 py-2 border-t border-sky-500/10 relative z-10">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span className="text-[10px] font-bold uppercase tracking-wider text-sky-200">Processing Engine Active</span>
</div>
</div>
<div className="bg-white/[0.03] border border-white/5 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="font-bold text-slate-200 text-sm uppercase tracking-widest">Fleet Control</h4>
<Link href="/admin" className="text-[10px] text-sky-400 hover:underline uppercase font-bold">Manage Hub</Link>
</div>
<div className="space-y-4">
<div className="p-3 bg-white/5 rounded-xl border border-white/5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-sky-500/10 flex items-center justify-center">
<Cpu className="w-4 h-4 text-sky-400" />
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold text-slate-300">Edge Provisioning</span>
<span className="text-[8px] text-slate-500 uppercase">Auto-Scale enabled</span>
</div>
</div>
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
</div>
</div>
</div>
</div>
</div>
</div>
);
}

139
src/app/tests/[id]/page.tsx Normal file
View File

@@ -0,0 +1,139 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { CheckCircle2, AlertCircle, Clock, Smartphone, Info, ShieldCheck, Gauge, ExternalLink } from "lucide-react";
import Link from "next/link";
async function getTestData(id: string) {
const test = await prisma.test.findUnique({
where: { id },
include: { device: true },
});
return test;
}
export default async function TestDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const test = await getTestData(id);
if (!test) {
notFound();
}
const diodeResults = test.diodeResults ? JSON.parse(test.diodeResults) as Record<string, number> : undefined;
return (
<div className="space-y-8 pb-20">
<div className="flex items-center gap-4 text-sm font-medium text-slate-500 mb-2">
<Link href="/" className="hover:text-sky-400 transition-colors">Dashboard</Link>
<span>/</span>
<span className="text-slate-300">Test Insights</span>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-2">
<div className="flex items-center gap-4">
<h1 className="text-3xl font-bold tracking-tight text-white">
Sequence Analysis: <span className="font-mono text-sky-400">{test.id.slice(-8).toUpperCase()}</span>
</h1>
{test.status === "PASS" ? (
<span className="bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 px-3 py-1 rounded-full text-xs font-black uppercase tracking-widest flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5" /> Nominal
</span>
) : (
<span className="bg-rose-500/10 text-rose-400 border border-rose-500/20 px-3 py-1 rounded-full text-xs font-black uppercase tracking-widest flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5" /> Anomaly Detected
</span>
)}
</div>
<p className="text-slate-400 flex items-center gap-2">
<Smartphone className="w-4 h-4" />
Origin: <span className="font-bold text-slate-200">{test.device.name || "Unknown Head Unit"}</span>
<span className="text-slate-600 font-mono text-xs">({test.device.serialNumber})</span>
</p>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-widest">Execution Timestamp</p>
<p className="text-sm font-bold text-slate-300">{new Date(test.timestamp).toLocaleString()}</p>
</div>
<div className="w-10 h-10 rounded-xl bg-white/5 border border-white/10 flex items-center justify-center">
<Clock className="w-5 h-5 text-slate-400" />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Spectral Analysis Card */}
<div className="lg:col-span-2 bg-white/[0.02] border border-white/5 rounded-3xl p-8 backdrop-blur-md relative overflow-hidden group">
<div className="absolute top-0 right-0 p-8 opacity-5">
<Gauge className="w-32 h-32" />
</div>
<div className="flex items-center gap-3 mb-8">
<Info className="w-5 h-5 text-sky-400" />
<h2 className="text-xs font-bold uppercase tracking-[0.2em] text-slate-500 italic">Spectral Diode Response</h2>
</div>
{diodeResults ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
{Object.entries(diodeResults).map(([pin, voltage]) => (
<div key={pin} className="space-y-2">
<div className="flex justify-between items-end text-[10px] font-bold uppercase tracking-wider">
<span className="text-slate-400">{pin.replace("_", " ")}</span>
<span className="text-sky-400 font-mono text-xs">{voltage.toFixed(3)}V</span>
</div>
<div className="h-1.5 w-full bg-white/5 rounded-full overflow-hidden border border-white/5">
<div
className="h-full bg-gradient-to-r from-sky-600 to-sky-400 rounded-full"
style={{ width: `${Math.min((voltage / 0.8) * 100, 100)}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12 text-slate-500 italic">
No detailed diode metrics available for this sequence.
</div>
)}
</div>
{/* Intelligence Report Summary */}
<div className="bg-white/[0.02] border border-white/5 rounded-3xl p-8 flex flex-col gap-6">
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-4 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-sky-400" />
Validation Report
</h3>
<div className="bg-white/5 border border-white/5 rounded-2xl p-4">
<p className="text-sm italic text-slate-300 leading-relaxed font-serif">
"{test.summary}"
</p>
</div>
</div>
<div className="mt-auto pt-6 border-t border-white/5">
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/[0.01] border border-white/5 rounded-xl p-3">
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Architecture</p>
<p className="text-xs font-bold text-slate-300">HDMI v1.4b</p>
</div>
<div className="bg-white/[0.01] border border-white/5 rounded-xl p-3">
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Probe Type</p>
<p className="text-xs font-bold text-slate-300">{test.type}</p>
</div>
</div>
<Link
href="/admin"
className="mt-6 flex items-center justify-center gap-2 w-full py-3 bg-white/5 hover:bg-white/10 border border-white/10 rounded-2xl text-[10px] font-black uppercase tracking-widest transition-all text-slate-300"
>
Re-Orchestrate Node <ExternalLink className="w-3 h-3" />
</Link>
</div>
</div>
</div>
</div>
);
}

7
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,7 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}