feat: Initialize Next.js project with Prisma, Tailwind CSS, and API routes for device management and testing.
This commit is contained in:
45
.agent/workflows/cli-tester.md
Normal file
45
.agent/workflows/cli-tester.md
Normal 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
43
.gitignore
vendored
Normal 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
36
README.md
Normal 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.
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal 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;
|
||||
6
firmware/config/device.json
Normal file
6
firmware/config/device.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"serial_number": "CB1FCDC3",
|
||||
"mode": "DEV",
|
||||
"active_slot": "A",
|
||||
"status": "ENROLLED"
|
||||
}
|
||||
138
firmware/simulator.py
Normal file
138
firmware/simulator.py
Normal 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
7
next.config.ts
Normal 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
6971
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal 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
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
37
prisma/migrations/20251223024517_init/migration.sql
Normal file
37
prisma/migrations/20251223024517_init/migration.sql
Normal 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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
42
prisma/schema.prisma
Normal 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
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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
152
src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/api/dashboard/stats/route.ts
Normal file
24
src/app/api/dashboard/stats/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
24
src/app/api/devices/[id]/route.ts
Normal file
24
src/app/api/devices/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
44
src/app/api/devices/register/route.ts
Normal file
44
src/app/api/devices/register/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
13
src/app/api/devices/route.ts
Normal file
13
src/app/api/devices/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
58
src/app/api/tests/report/route.ts
Normal file
58
src/app/api/tests/report/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
src/app/globals.css
Normal file
26
src/app/globals.css
Normal 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
55
src/app/layout.tsx
Normal 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">
|
||||
© 2025 HDMI Tester Enterprise. All rights reserved.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
257
src/app/page.tsx
Normal file
257
src/app/page.tsx
Normal 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
139
src/app/tests/[id]/page.tsx
Normal 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
7
src/lib/prisma.ts
Normal 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
34
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user