create-autoid-app 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/index.js +107 -0
- package/package.json +25 -4
- package/templates/dotnet-gate-service/GateWorker.cs +65 -0
- package/templates/dotnet-gate-service/Program.cs +20 -0
- package/templates/dotnet-gate-service/README.md +11 -0
- package/templates/dotnet-gate-service/__NAME__.csproj +15 -0
- package/templates/dotnet-gate-service/appsettings.json +13 -0
- package/templates/node-stream-worker/README.md +10 -0
- package/templates/node-stream-worker/index.js +60 -0
- package/templates/node-stream-worker/package.json +12 -0
- package/templates/web-radar-dashboard/README.md +20 -0
- package/templates/web-radar-dashboard/index.html +92 -0
- package/templates/web-radar-dashboard/package.json +17 -0
- package/templates/web-radar-dashboard/src/main.js +276 -0
- package/templates/web-radar-dashboard/src/style.css +304 -0
- package/test/cli.test.js +37 -0
- package/README.md +0 -3
package/bin/index.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const readline = require('readline');
|
|
6
|
+
|
|
7
|
+
const BANNER = `
|
|
8
|
+
\x1b[36m╔═════════════════════════════════════════════════════════════════════════╗
|
|
9
|
+
║ ⚡ BEETECH OMNIAUTO-ID™ DEVELOPER APPLICATION GENERATOR ⚡ ║
|
|
10
|
+
║ Zero-Hardware to Real-Time RFID Streaming in 60s ║
|
|
11
|
+
╚═════════════════════════════════════════════════════════════════════════╝\x1b[0m
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
async function promptQuestion(query, defaultValue) {
|
|
15
|
+
const rl = readline.createInterface({
|
|
16
|
+
input: process.stdin,
|
|
17
|
+
output: process.stdout
|
|
18
|
+
});
|
|
19
|
+
return new Promise(resolve => {
|
|
20
|
+
rl.question(`\x1b[33m? \x1b[1m${query}\x1b[0m \x1b[90m(${defaultValue}):\x1b[0m `, answer => {
|
|
21
|
+
rl.close();
|
|
22
|
+
resolve(answer.trim() || defaultValue);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function main() {
|
|
28
|
+
console.log(BANNER);
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
let projectName = args[0];
|
|
32
|
+
|
|
33
|
+
if (!projectName) {
|
|
34
|
+
projectName = await promptQuestion('Enter your project name', 'my-rfid-app');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`\n\x1b[32m✔\x1b[0m Target Directory: \x1b[36m${projectName}\x1b[0m`);
|
|
38
|
+
|
|
39
|
+
console.log('\n\x1b[1mAvailable Application Templates:\x1b[0m');
|
|
40
|
+
console.log(' \x1b[36m[1]\x1b[0m \x1b[1mweb-radar-dashboard\x1b[0m - Modern Realtime Web Radar & Tag Inspector (Vite + Live UI)');
|
|
41
|
+
console.log(' \x1b[36m[2]\x1b[0m \x1b[1mnode-stream-worker\x1b[0m - Node.js High-Throughput Tag Stream Consumer');
|
|
42
|
+
console.log(' \x1b[36m[3]\x1b[0m \x1b[1mdotnet-gate-service\x1b[0m - C# .NET 9 Warehouse Inbound/Outbound RFID Gate Service');
|
|
43
|
+
|
|
44
|
+
let choice = args[1] || await promptQuestion('Select template [1-3]', '1');
|
|
45
|
+
let templateKey = 'web-radar-dashboard';
|
|
46
|
+
if (choice === '2' || choice === 'node-stream-worker') templateKey = 'node-stream-worker';
|
|
47
|
+
if (choice === '3' || choice === 'dotnet-gate-service') templateKey = 'dotnet-gate-service';
|
|
48
|
+
|
|
49
|
+
const targetDir = path.resolve(process.cwd(), projectName);
|
|
50
|
+
if (fs.existsSync(targetDir)) {
|
|
51
|
+
console.error(`\x1b[31m✖ Error: Directory '${projectName}' already exists.\x1b[0m`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log(`\n\x1b[34m[1/3]\x1b[0m Creating project directory: \x1b[36m${targetDir}\x1b[0m`);
|
|
56
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
57
|
+
|
|
58
|
+
console.log(`\x1b[34m[2/3]\x1b[0m Generating template \x1b[35m${templateKey}\x1b[0m...`);
|
|
59
|
+
const templateSourceDir = path.join(__dirname, '..', 'templates', templateKey);
|
|
60
|
+
|
|
61
|
+
if (fs.existsSync(templateSourceDir)) {
|
|
62
|
+
copyDirectoryRecursive(templateSourceDir, targetDir, { projectName });
|
|
63
|
+
} else {
|
|
64
|
+
console.error(`\x1b[31m✖ Template directory not found: ${templateSourceDir}\x1b[0m`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.log(`\x1b[34m[3/3]\x1b[0m Ready!`);
|
|
69
|
+
console.log(`
|
|
70
|
+
\x1b[32m✔ Successfully created '${projectName}'!\x1b[0m
|
|
71
|
+
|
|
72
|
+
To launch your application in 30 seconds:
|
|
73
|
+
\x1b[33mcd ${projectName}\x1b[0m
|
|
74
|
+
${templateKey.startsWith('dotnet') ? '\x1b[33mdotnet run\x1b[0m' : '\x1b[33mnpm install\n npm run dev\x1b[0m'}
|
|
75
|
+
|
|
76
|
+
\x1b[90mTip: If no physical RFID reader is connected, the app automatically switches to\x1b[0m
|
|
77
|
+
\x1b[90mZero-Hardware Simulator Mode with realistic GS1 SGTIN tags & radar animation!\x1b[0m
|
|
78
|
+
`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function copyDirectoryRecursive(src, dest, replacements) {
|
|
82
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const srcPath = path.join(src, entry.name);
|
|
85
|
+
let destName = entry.name;
|
|
86
|
+
if (destName.includes('__NAME__')) {
|
|
87
|
+
destName = destName.replace('__NAME__', replacements.projectName);
|
|
88
|
+
}
|
|
89
|
+
const destPath = path.join(dest, destName);
|
|
90
|
+
|
|
91
|
+
if (entry.isDirectory()) {
|
|
92
|
+
fs.mkdirSync(destPath, { recursive: true });
|
|
93
|
+
copyDirectoryRecursive(srcPath, destPath, replacements);
|
|
94
|
+
} else {
|
|
95
|
+
let content = fs.readFileSync(srcPath, 'utf8');
|
|
96
|
+
for (const [key, val] of Object.entries(replacements)) {
|
|
97
|
+
content = content.replace(new RegExp(`{{${key}}}`, 'g'), val);
|
|
98
|
+
}
|
|
99
|
+
fs.writeFileSync(destPath, content, 'utf8');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main().catch(err => {
|
|
105
|
+
console.error('\x1b[31mUnexpected CLI error:\x1b[0m', err);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-autoid-app",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Scaffold real-time RFID, PLC, and Barcode IoT applications with Beetech OmniAuto-ID and Adv.SmartSdk in under 60 seconds",
|
|
5
|
+
"main": "bin/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-autoid-app": "./bin/index.js",
|
|
8
|
+
"create-omniautoid-app": "./bin/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node bin/index.js",
|
|
12
|
+
"test": "node test/cli.test.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"rfid",
|
|
16
|
+
"autoid",
|
|
17
|
+
"beetech",
|
|
18
|
+
"smartsdk",
|
|
19
|
+
"edge-gateway",
|
|
20
|
+
"iot",
|
|
21
|
+
"scaffolding",
|
|
22
|
+
"cli",
|
|
23
|
+
"npx"
|
|
24
|
+
],
|
|
25
|
+
"author": "Beetech Auto-ID Engineering Team",
|
|
26
|
+
"license": "MIT"
|
|
27
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Concurrent;
|
|
3
|
+
using System.Threading;
|
|
4
|
+
using System.Threading.Tasks;
|
|
5
|
+
using Beetech.OmniAutoId.Client;
|
|
6
|
+
using Beetech.OmniAutoId.Client.Models;
|
|
7
|
+
using Microsoft.Extensions.Hosting;
|
|
8
|
+
using Microsoft.Extensions.Logging;
|
|
9
|
+
|
|
10
|
+
namespace {{projectName}};
|
|
11
|
+
|
|
12
|
+
/// <summary>
|
|
13
|
+
/// Background worker processing high-speed RFID Portal Gate events, deduplication, and PLC trigger actions.
|
|
14
|
+
/// </summary>
|
|
15
|
+
public sealed class GateWorker : BackgroundService
|
|
16
|
+
{
|
|
17
|
+
private readonly IOmniAutoIdClient _client;
|
|
18
|
+
private readonly ILogger<GateWorker> _logger;
|
|
19
|
+
private readonly ConcurrentDictionary<string, (DateTime FirstSeen, int Count)> _tagCache = new();
|
|
20
|
+
private long _totalTagReads;
|
|
21
|
+
|
|
22
|
+
public GateWorker(IOmniAutoIdClient client, ILogger<GateWorker> logger)
|
|
23
|
+
{
|
|
24
|
+
_client = client;
|
|
25
|
+
_logger = logger;
|
|
26
|
+
|
|
27
|
+
_client.TagRead += OnTagDetected;
|
|
28
|
+
_client.ConnectionStatusChanged += (s, e) =>
|
|
29
|
+
{
|
|
30
|
+
_logger.LogInformation(">>> OmniAuto-ID Gateway Status: {Status} (Simulated: {IsSim})", e.Message, e.IsSimulated);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
35
|
+
{
|
|
36
|
+
_logger.LogInformation("Warehouse Portal Gate Worker is starting...");
|
|
37
|
+
|
|
38
|
+
await _client.ConnectAsync(stoppingToken);
|
|
39
|
+
|
|
40
|
+
while (!stoppingToken.IsCancellationRequested)
|
|
41
|
+
{
|
|
42
|
+
await Task.Delay(5000, stoppingToken);
|
|
43
|
+
_logger.LogInformation("[GATE STATUS] Total Tag Scans: {Total} | Unique EPCs: {Unique}",
|
|
44
|
+
Interlocked.Read(ref _totalTagReads), _tagCache.Count);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await _client.DisconnectAsync(stoppingToken);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private void OnTagDetected(object? sender, TagReadEvent tag)
|
|
51
|
+
{
|
|
52
|
+
Interlocked.Increment(ref _totalTagReads);
|
|
53
|
+
|
|
54
|
+
var isNew = !_tagCache.ContainsKey(tag.Epc);
|
|
55
|
+
_tagCache.AddOrUpdate(tag.Epc,
|
|
56
|
+
(DateTime.UtcNow, 1),
|
|
57
|
+
(_, old) => (old.FirstSeen, old.Count + 1));
|
|
58
|
+
|
|
59
|
+
if (isNew)
|
|
60
|
+
{
|
|
61
|
+
_logger.LogInformation("[PALLET ARRIVAL] EPC: {Epc} | Ant: {Ant} | RSSI: {Rssi} dBm | Direction: {Dir}",
|
|
62
|
+
tag.Epc, tag.Antenna, tag.Rssi, tag.Direction);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
using Beetech.OmniAutoId.Client;
|
|
2
|
+
using Microsoft.Extensions.DependencyInjection;
|
|
3
|
+
using Microsoft.Extensions.Hosting;
|
|
4
|
+
using {{projectName}};
|
|
5
|
+
|
|
6
|
+
var builder = Host.CreateApplicationBuilder(args);
|
|
7
|
+
|
|
8
|
+
// Register OmniAuto-ID Client with configuration
|
|
9
|
+
builder.Services.AddOmniAutoIdClient(options =>
|
|
10
|
+
{
|
|
11
|
+
options.GatewayUrl = builder.Configuration["OmniAutoId:GatewayUrl"] ?? "http://localhost:19090";
|
|
12
|
+
options.ApiKey = builder.Configuration["OmniAutoId:ApiKey"];
|
|
13
|
+
options.UseSimulationFallback = true; // Auto-switches to high-speed virtual simulator if hardware is offline
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Register Warehouse Gate Background Worker
|
|
17
|
+
builder.Services.AddHostedService<GateWorker>();
|
|
18
|
+
|
|
19
|
+
var host = builder.Build();
|
|
20
|
+
host.Run();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
High-throughput Warehouse Portal Gate Service built with **Beetech OmniAuto-ID™ Client**.
|
|
4
|
+
|
|
5
|
+
## 🚀 Running the Service
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
dotnet run
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
If the physical edge gateway is offline, the service automatically engages the built-in Zero-Hardware Simulator, generating realistic EPC Gen2 pallet scans so you can test your warehouse logic instantly.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
|
2
|
+
|
|
3
|
+
<PropertyGroup>
|
|
4
|
+
<TargetFramework>net8.0</TargetFramework>
|
|
5
|
+
<Nullable>enable</Nullable>
|
|
6
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
7
|
+
<UserSecretsId>dotnet-{{projectName}}-secrets</UserSecretsId>
|
|
8
|
+
</PropertyGroup>
|
|
9
|
+
|
|
10
|
+
<ItemGroup>
|
|
11
|
+
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
|
12
|
+
<PackageReference Include="Beetech.OmniAutoId.Client" Version="1.0.0" />
|
|
13
|
+
</ItemGroup>
|
|
14
|
+
|
|
15
|
+
</Project>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const WebSocket = require('ws');
|
|
2
|
+
|
|
3
|
+
const GATEWAY_WS_URL = process.env.AUTOID_GATEWAY_WS || 'ws://localhost:19090/stream';
|
|
4
|
+
console.log('===============================================================');
|
|
5
|
+
console.log(' ⚡ OmniAuto-ID™ High-Throughput Stream Worker Daemon ⚡ ');
|
|
6
|
+
console.log(` Target Gateway: ${GATEWAY_WS_URL}`);
|
|
7
|
+
console.log('===============================================================');
|
|
8
|
+
|
|
9
|
+
let totalProcessed = 0;
|
|
10
|
+
let tagsInLastSecond = 0;
|
|
11
|
+
const inventoryCache = new Map();
|
|
12
|
+
|
|
13
|
+
function startWorker() {
|
|
14
|
+
console.log(`Connecting to ${GATEWAY_WS_URL}...`);
|
|
15
|
+
const ws = new WebSocket(GATEWAY_WS_URL);
|
|
16
|
+
|
|
17
|
+
ws.on('open', () => {
|
|
18
|
+
console.log('✔ Connected to OmniAuto-ID Live Stream.');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
ws.on('message', (data) => {
|
|
22
|
+
try {
|
|
23
|
+
const tag = JSON.parse(data.toString());
|
|
24
|
+
if (!tag.epc) return;
|
|
25
|
+
|
|
26
|
+
totalProcessed++;
|
|
27
|
+
tagsInLastSecond++;
|
|
28
|
+
|
|
29
|
+
const cache = inventoryCache.get(tag.epc) || { count: 0, firstSeen: Date.now() };
|
|
30
|
+
cache.count++;
|
|
31
|
+
cache.lastRssi = tag.rssi;
|
|
32
|
+
inventoryCache.set(tag.epc, cache);
|
|
33
|
+
|
|
34
|
+
if (cache.count === 1) {
|
|
35
|
+
console.log(`[NEW TAG DETECTED] EPC=${tag.epc} | Ant=${tag.antenna} | RSSI=${tag.rssi} dBm | TotalUnique=${inventoryCache.size}`);
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error('Error parsing stream event:', err.message);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
ws.on('error', (err) => {
|
|
43
|
+
console.warn(`Connection error (${err.message}). Retrying in 3s...`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
ws.on('close', () => {
|
|
47
|
+
console.warn('Stream closed. Reconnecting in 3s...');
|
|
48
|
+
setTimeout(startWorker, 3000);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Stats summary interval
|
|
53
|
+
setInterval(() => {
|
|
54
|
+
if (totalProcessed > 0) {
|
|
55
|
+
console.log(`[METRICS] Rate: ${tagsInLastSecond} tags/sec | Unique EPCs: ${inventoryCache.size} | Total Reads: ${totalProcessed}`);
|
|
56
|
+
tagsInLastSecond = 0;
|
|
57
|
+
}
|
|
58
|
+
}, 2000);
|
|
59
|
+
|
|
60
|
+
startWorker();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
Real-time RFID Radar & Tag Inventory Dashboard built with **Beetech OmniAuto-ID™ Platform**.
|
|
4
|
+
|
|
5
|
+
## 🚀 Getting Started
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 1. Install dependencies
|
|
9
|
+
npm install
|
|
10
|
+
|
|
11
|
+
# 2. Run local dev server
|
|
12
|
+
npm run dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Open your browser at the local URL (usually `http://localhost:5173`).
|
|
16
|
+
|
|
17
|
+
## ⚡ Features
|
|
18
|
+
* **Zero-Hardware Simulation:** Automatically starts in mock mode if the OmniAuto-ID Gateway (`ws://localhost:19090/stream`) is not detected.
|
|
19
|
+
* **Canvas Radar Sweep:** Real-time spatial visualization of tag reads across antennas.
|
|
20
|
+
* **Live In-Memory Deduplication:** Aggregates read counts, RSSI signals, and inbound/outbound direction tracking.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>{{projectName}} - OmniAuto-ID Live RFID Radar</title>
|
|
7
|
+
<link rel="stylesheet" href="./src/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="app-container">
|
|
11
|
+
<header class="navbar">
|
|
12
|
+
<div class="brand">
|
|
13
|
+
<div class="pulse-indicator live" id="statusPulse"></div>
|
|
14
|
+
<div class="title-group">
|
|
15
|
+
<h1>OmniAuto-ID™ Live Radar</h1>
|
|
16
|
+
<span class="subtitle">{{projectName}} • Port 19090 Stream</span>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="header-controls">
|
|
20
|
+
<button id="toggleSimBtn" class="btn btn-secondary">Simulation: <span id="simStateText">AUTO</span></button>
|
|
21
|
+
<button id="clearBtn" class="btn btn-secondary">Clear Table</button>
|
|
22
|
+
<span class="badge" id="connBadge">Connecting...</span>
|
|
23
|
+
</div>
|
|
24
|
+
</header>
|
|
25
|
+
|
|
26
|
+
<main class="dashboard-grid">
|
|
27
|
+
<!-- Top Metric Cards -->
|
|
28
|
+
<section class="metric-card">
|
|
29
|
+
<span class="card-label">TOTAL TAGS PROCESSED</span>
|
|
30
|
+
<div class="card-value" id="totalTagsCount">0</div>
|
|
31
|
+
<span class="card-hint">Deduplicated in memory</span>
|
|
32
|
+
</section>
|
|
33
|
+
|
|
34
|
+
<section class="metric-card">
|
|
35
|
+
<span class="card-label">CURRENT SCAN VELOCITY</span>
|
|
36
|
+
<div class="card-value text-accent" id="tagRate">0 <span class="unit">tags/s</span></div>
|
|
37
|
+
<span class="card-hint">Sub-millisecond processing</span>
|
|
38
|
+
</section>
|
|
39
|
+
|
|
40
|
+
<section class="metric-card">
|
|
41
|
+
<span class="card-label">ACTIVE ANTENNAS</span>
|
|
42
|
+
<div class="card-value" id="activeAntennas">4</div>
|
|
43
|
+
<span class="card-hint">Port 1, 2 (In) • 3, 4 (Out)</span>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section class="metric-card">
|
|
47
|
+
<span class="card-label">GATEWAY LATENCY</span>
|
|
48
|
+
<div class="card-value text-green" id="latencyMs">1.2 <span class="unit">ms</span></div>
|
|
49
|
+
<span class="card-hint">Native AOT WebSocket engine</span>
|
|
50
|
+
</section>
|
|
51
|
+
|
|
52
|
+
<!-- Radar Visualizer -->
|
|
53
|
+
<section class="panel radar-panel">
|
|
54
|
+
<div class="panel-header">
|
|
55
|
+
<h2>Antenna Beam Coverage & Tag Radar</h2>
|
|
56
|
+
<span class="hint">Spatial RSSI Distribution</span>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="radar-container">
|
|
59
|
+
<canvas id="radarCanvas" width="400" height="400"></canvas>
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
<!-- Live Scanned Stream Table -->
|
|
64
|
+
<section class="panel table-panel">
|
|
65
|
+
<div class="panel-header">
|
|
66
|
+
<h2>Live EPC Tag Inventory Feed</h2>
|
|
67
|
+
<span class="counter-badge" id="tableRowCount">0 detected</span>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="table-scroll">
|
|
70
|
+
<table class="tag-table">
|
|
71
|
+
<thead>
|
|
72
|
+
<tr>
|
|
73
|
+
<th>TIME</th>
|
|
74
|
+
<th>EPC / GS1 IDENTIFIER</th>
|
|
75
|
+
<th>ANTENNA</th>
|
|
76
|
+
<th>RSSI</th>
|
|
77
|
+
<th>DIRECTION</th>
|
|
78
|
+
<th>READS</th>
|
|
79
|
+
</tr>
|
|
80
|
+
</thead>
|
|
81
|
+
<tbody id="tagTableBody">
|
|
82
|
+
<!-- Dynamically populated rows -->
|
|
83
|
+
</tbody>
|
|
84
|
+
</table>
|
|
85
|
+
</div>
|
|
86
|
+
</section>
|
|
87
|
+
</main>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<script type="module" src="./src/main.js"></script>
|
|
91
|
+
</body>
|
|
92
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@beetech-autoid/smartsdk-client": "^1.0.3"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"vite": "^5.1.0"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// OmniAuto-ID Live RFID Radar Engine
|
|
2
|
+
const canvas = document.getElementById('radarCanvas');
|
|
3
|
+
const ctx = canvas.getContext('2d');
|
|
4
|
+
const tableBody = document.getElementById('tagTableBody');
|
|
5
|
+
const totalTagsEl = document.getElementById('totalTagsCount');
|
|
6
|
+
const tagRateEl = document.getElementById('tagRate');
|
|
7
|
+
const connBadge = document.getElementById('connBadge');
|
|
8
|
+
const tableRowCountEl = document.getElementById('tableRowCount');
|
|
9
|
+
const toggleSimBtn = document.getElementById('toggleSimBtn');
|
|
10
|
+
const simStateText = document.getElementById('simStateText');
|
|
11
|
+
const clearBtn = document.getElementById('clearBtn');
|
|
12
|
+
|
|
13
|
+
const tagsMap = new Map();
|
|
14
|
+
const radarBlips = [];
|
|
15
|
+
let totalScans = 0;
|
|
16
|
+
let scansLastSecond = 0;
|
|
17
|
+
let isSimulating = false;
|
|
18
|
+
let simulationTimer = null;
|
|
19
|
+
let ws = null;
|
|
20
|
+
|
|
21
|
+
// Synthetic Audio Bip Effect via Web Audio API
|
|
22
|
+
let audioCtx = null;
|
|
23
|
+
function playScanBeep(frequency = 1200) {
|
|
24
|
+
try {
|
|
25
|
+
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
26
|
+
const osc = audioCtx.createOscillator();
|
|
27
|
+
const gain = audioCtx.createGain();
|
|
28
|
+
osc.type = 'sine';
|
|
29
|
+
osc.frequency.setValueAtTime(frequency, audioCtx.currentTime);
|
|
30
|
+
gain.gain.setValueAtTime(0.04, audioCtx.currentTime);
|
|
31
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime + 0.05);
|
|
32
|
+
osc.connect(gain);
|
|
33
|
+
gain.connect(audioCtx.destination);
|
|
34
|
+
osc.start();
|
|
35
|
+
osc.stop(audioCtx.currentTime + 0.06);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
// Audio context may require user gesture on some browsers
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 1. Process and Render Incoming Tag Event
|
|
42
|
+
function handleTagEvent(tag) {
|
|
43
|
+
totalScans++;
|
|
44
|
+
scansLastSecond++;
|
|
45
|
+
totalTagsEl.textContent = totalScans.toLocaleString();
|
|
46
|
+
|
|
47
|
+
const isNew = !tagsMap.has(tag.epc);
|
|
48
|
+
const existing = tagsMap.get(tag.epc) || { count: 0, firstSeen: new Date() };
|
|
49
|
+
existing.count++;
|
|
50
|
+
existing.lastSeen = new Date();
|
|
51
|
+
existing.rssi = tag.rssi;
|
|
52
|
+
existing.antenna = tag.antenna || 1;
|
|
53
|
+
existing.direction = tag.direction || (tag.antenna <= 2 ? 'Inbound' : 'Outbound');
|
|
54
|
+
tagsMap.set(tag.epc, existing);
|
|
55
|
+
|
|
56
|
+
// Add radar blip
|
|
57
|
+
const angle = Math.random() * Math.PI * 2;
|
|
58
|
+
const distRatio = Math.min(1, Math.max(0.1, (Math.abs(tag.rssi) - 30) / 50));
|
|
59
|
+
const maxR = canvas.width / 2 - 20;
|
|
60
|
+
radarBlips.push({
|
|
61
|
+
x: canvas.width / 2 + Math.cos(angle) * (distRatio * maxR),
|
|
62
|
+
y: canvas.height / 2 + Math.sin(angle) * (distRatio * maxR),
|
|
63
|
+
alpha: 1.0,
|
|
64
|
+
color: existing.direction === 'Inbound' ? '#00ff88' : '#00f0ff'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
playScanBeep(existing.direction === 'Inbound' ? 1400 : 980);
|
|
68
|
+
updateTableRow(tag.epc, existing, isNew);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function updateTableRow(epc, info, isNew) {
|
|
72
|
+
let row = document.getElementById(`row-${epc}`);
|
|
73
|
+
const timeStr = info.lastSeen.toLocaleTimeString();
|
|
74
|
+
|
|
75
|
+
if (!row) {
|
|
76
|
+
row = document.createElement('tr');
|
|
77
|
+
row.id = `row-${epc}`;
|
|
78
|
+
row.className = 'tag-row newly-added';
|
|
79
|
+
tableBody.insertBefore(row, tableBody.firstChild);
|
|
80
|
+
tableRowCountEl.textContent = `${tagsMap.size} unique EPCs`;
|
|
81
|
+
} else {
|
|
82
|
+
row.classList.remove('newly-added');
|
|
83
|
+
void row.offsetWidth; // Trigger reflow for CSS animation
|
|
84
|
+
row.classList.add('newly-added');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const dirClass = info.direction === 'Inbound' ? 'dir-in' : 'dir-out';
|
|
88
|
+
const rssiWidth = Math.min(100, Math.max(10, 100 - (Math.abs(info.rssi) - 30) * 2));
|
|
89
|
+
|
|
90
|
+
row.innerHTML = `
|
|
91
|
+
<td>${timeStr}</td>
|
|
92
|
+
<td><strong>${epc}</strong></td>
|
|
93
|
+
<td>Port ${info.antenna}</td>
|
|
94
|
+
<td>
|
|
95
|
+
<div class="rssi-bar-container">
|
|
96
|
+
<span>${info.rssi.toFixed(1)} dBm</span>
|
|
97
|
+
<div class="rssi-fill" style="width: ${rssiWidth}px;"></div>
|
|
98
|
+
</div>
|
|
99
|
+
</td>
|
|
100
|
+
<td><span class="dir-badge ${dirClass}">${info.direction}</span></td>
|
|
101
|
+
<td><strong>${info.count}</strong></td>
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. Radar Canvas Visualizer Loop
|
|
106
|
+
let sweepAngle = 0;
|
|
107
|
+
function drawRadar() {
|
|
108
|
+
const cx = canvas.width / 2;
|
|
109
|
+
const cy = canvas.height / 2;
|
|
110
|
+
const radius = cx - 15;
|
|
111
|
+
|
|
112
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
113
|
+
|
|
114
|
+
// Concentric radar circles
|
|
115
|
+
ctx.strokeStyle = '#1e293b';
|
|
116
|
+
ctx.lineWidth = 1;
|
|
117
|
+
for (let r = radius / 4; r <= radius; r += radius / 4) {
|
|
118
|
+
ctx.beginPath();
|
|
119
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
120
|
+
ctx.stroke();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Crosshairs
|
|
124
|
+
ctx.beginPath();
|
|
125
|
+
ctx.moveTo(cx - radius, cy);
|
|
126
|
+
ctx.lineTo(cx + radius, cy);
|
|
127
|
+
ctx.moveTo(cx, cy - radius);
|
|
128
|
+
ctx.lineTo(cx, cy + radius);
|
|
129
|
+
ctx.stroke();
|
|
130
|
+
|
|
131
|
+
// Rotating sweep beam
|
|
132
|
+
ctx.save();
|
|
133
|
+
ctx.translate(cx, cy);
|
|
134
|
+
ctx.rotate(sweepAngle);
|
|
135
|
+
const gradient = ctx.createLinearGradient(0, 0, radius, 0);
|
|
136
|
+
gradient.addColorStop(0, 'rgba(0, 240, 255, 0.4)');
|
|
137
|
+
gradient.addColorStop(1, 'rgba(0, 240, 255, 0)');
|
|
138
|
+
ctx.fillStyle = gradient;
|
|
139
|
+
ctx.beginPath();
|
|
140
|
+
ctx.moveTo(0, 0);
|
|
141
|
+
ctx.arc(0, 0, radius, -0.3, 0.3);
|
|
142
|
+
ctx.closePath();
|
|
143
|
+
ctx.fill();
|
|
144
|
+
ctx.restore();
|
|
145
|
+
|
|
146
|
+
sweepAngle += 0.04;
|
|
147
|
+
|
|
148
|
+
// Render glowing blips
|
|
149
|
+
for (let i = radarBlips.length - 1; i >= 0; i--) {
|
|
150
|
+
const blip = radarBlips[i];
|
|
151
|
+
ctx.beginPath();
|
|
152
|
+
ctx.arc(blip.x, blip.y, 4, 0, Math.PI * 2);
|
|
153
|
+
ctx.fillStyle = blip.color;
|
|
154
|
+
ctx.globalAlpha = blip.alpha;
|
|
155
|
+
ctx.fill();
|
|
156
|
+
ctx.globalAlpha = 1.0;
|
|
157
|
+
|
|
158
|
+
blip.alpha -= 0.015;
|
|
159
|
+
if (blip.alpha <= 0) {
|
|
160
|
+
radarBlips.splice(i, 1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
requestAnimationFrame(drawRadar);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 3. Rate Meter Interval
|
|
168
|
+
setInterval(() => {
|
|
169
|
+
tagRateEl.innerHTML = `${scansLastSecond} <span class="unit">tags/s</span>`;
|
|
170
|
+
scansLastSecond = 0;
|
|
171
|
+
}, 1000);
|
|
172
|
+
|
|
173
|
+
// 4. WebSocket Connection with Auto-Fallback
|
|
174
|
+
function connectGateway() {
|
|
175
|
+
const wsUrl = 'ws://localhost:19090/stream';
|
|
176
|
+
connBadge.textContent = 'Connecting...';
|
|
177
|
+
connBadge.className = 'badge';
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
ws = new WebSocket(wsUrl);
|
|
181
|
+
|
|
182
|
+
ws.onopen = () => {
|
|
183
|
+
connBadge.textContent = 'LIVE GATEWAY';
|
|
184
|
+
connBadge.className = 'badge connected';
|
|
185
|
+
stopSimulation();
|
|
186
|
+
console.log('Connected to OmniAuto-ID Edge Gateway on port 19090');
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
ws.onmessage = (event) => {
|
|
190
|
+
try {
|
|
191
|
+
const data = JSON.parse(event.data);
|
|
192
|
+
if (data.epc) {
|
|
193
|
+
handleTagEvent(data);
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
console.error('Failed to parse tag event:', err);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
ws.onerror = () => {
|
|
201
|
+
fallbackToSimulation();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
ws.onclose = () => {
|
|
205
|
+
fallbackToSimulation();
|
|
206
|
+
};
|
|
207
|
+
} catch (e) {
|
|
208
|
+
fallbackToSimulation();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function fallbackToSimulation() {
|
|
213
|
+
if (isSimulating) return;
|
|
214
|
+
connBadge.textContent = 'SIMULATOR MODE';
|
|
215
|
+
connBadge.className = 'badge simulated';
|
|
216
|
+
startSimulation();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function startSimulation() {
|
|
220
|
+
isSimulating = true;
|
|
221
|
+
simStateText.textContent = 'ON';
|
|
222
|
+
const sampleEpcs = [
|
|
223
|
+
'3034257BF400B7800004FB11',
|
|
224
|
+
'3034257BF400B7800004FB12',
|
|
225
|
+
'3034257BF400B7800004FB13',
|
|
226
|
+
'E2801160600002047E7A1001',
|
|
227
|
+
'E2801160600002047E7A1002',
|
|
228
|
+
'E2000019380902481840E1B4'
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
simulationTimer = setInterval(() => {
|
|
232
|
+
const epc = sampleEpcs[Math.floor(Math.random() * sampleEpcs.length)];
|
|
233
|
+
const antenna = Math.floor(Math.random() * 4) + 1;
|
|
234
|
+
const rssi = -42 - Math.random() * 25;
|
|
235
|
+
handleTagEvent({
|
|
236
|
+
epc,
|
|
237
|
+
antenna,
|
|
238
|
+
rssi,
|
|
239
|
+
direction: antenna <= 2 ? 'Inbound' : 'Outbound'
|
|
240
|
+
});
|
|
241
|
+
}, 120);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function stopSimulation() {
|
|
245
|
+
isSimulating = false;
|
|
246
|
+
simStateText.textContent = 'OFF';
|
|
247
|
+
if (simulationTimer) {
|
|
248
|
+
clearInterval(simulationTimer);
|
|
249
|
+
simulationTimer = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// User Actions
|
|
254
|
+
toggleSimBtn.addEventListener('click', () => {
|
|
255
|
+
if (isSimulating) {
|
|
256
|
+
stopSimulation();
|
|
257
|
+
connBadge.textContent = 'PAUSED';
|
|
258
|
+
connBadge.className = 'badge';
|
|
259
|
+
} else {
|
|
260
|
+
startSimulation();
|
|
261
|
+
connBadge.textContent = 'SIMULATOR MODE';
|
|
262
|
+
connBadge.className = 'badge simulated';
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
clearBtn.addEventListener('click', () => {
|
|
267
|
+
tagsMap.clear();
|
|
268
|
+
tableBody.innerHTML = '';
|
|
269
|
+
tableRowCountEl.textContent = '0 detected';
|
|
270
|
+
totalScans = 0;
|
|
271
|
+
totalTagsEl.textContent = '0';
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Boot up
|
|
275
|
+
drawRadar();
|
|
276
|
+
connectGateway();
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg-primary: #0a0d14;
|
|
3
|
+
--bg-surface: #121824;
|
|
4
|
+
--bg-surface-elevated: #1a2234;
|
|
5
|
+
--border: #232e45;
|
|
6
|
+
--accent-cyan: #00f0ff;
|
|
7
|
+
--accent-green: #00ff88;
|
|
8
|
+
--accent-amber: #ffaa00;
|
|
9
|
+
--text-main: #f0f4f8;
|
|
10
|
+
--text-muted: #8a99ad;
|
|
11
|
+
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
* {
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
margin: 0;
|
|
17
|
+
padding: 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
body {
|
|
21
|
+
background-color: var(--bg-primary);
|
|
22
|
+
color: var(--text-main);
|
|
23
|
+
font-family: var(--font-family);
|
|
24
|
+
min-height: 100vh;
|
|
25
|
+
display: flex;
|
|
26
|
+
flex-direction: column;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.app-container {
|
|
30
|
+
max-width: 1400px;
|
|
31
|
+
margin: 0 auto;
|
|
32
|
+
padding: 1.5rem;
|
|
33
|
+
width: 100%;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.navbar {
|
|
37
|
+
display: flex;
|
|
38
|
+
justify-content: space-between;
|
|
39
|
+
align-items: center;
|
|
40
|
+
padding: 1rem 1.5rem;
|
|
41
|
+
background: var(--bg-surface);
|
|
42
|
+
border: 1px solid var(--border);
|
|
43
|
+
border-radius: 12px;
|
|
44
|
+
margin-bottom: 1.5rem;
|
|
45
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.brand {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 1rem;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.pulse-indicator {
|
|
55
|
+
width: 12px;
|
|
56
|
+
height: 12px;
|
|
57
|
+
border-radius: 50%;
|
|
58
|
+
background: var(--accent-green);
|
|
59
|
+
box-shadow: 0 0 12px var(--accent-green);
|
|
60
|
+
animation: pulse 2s infinite;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@keyframes pulse {
|
|
64
|
+
0% { transform: scale(0.95); opacity: 0.8; }
|
|
65
|
+
50% { transform: scale(1.2); opacity: 1; }
|
|
66
|
+
100% { transform: scale(0.95); opacity: 0.8; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.title-group h1 {
|
|
70
|
+
font-size: 1.25rem;
|
|
71
|
+
font-weight: 700;
|
|
72
|
+
letter-spacing: -0.02em;
|
|
73
|
+
background: linear-gradient(90deg, #fff, var(--accent-cyan));
|
|
74
|
+
-webkit-background-clip: text;
|
|
75
|
+
-webkit-text-fill-color: transparent;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.subtitle {
|
|
79
|
+
font-size: 0.75rem;
|
|
80
|
+
color: var(--text-muted);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.header-controls {
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
gap: 0.75rem;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.btn {
|
|
90
|
+
background: var(--bg-surface-elevated);
|
|
91
|
+
border: 1px solid var(--border);
|
|
92
|
+
color: var(--text-main);
|
|
93
|
+
padding: 0.45rem 0.9rem;
|
|
94
|
+
border-radius: 6px;
|
|
95
|
+
font-size: 0.8rem;
|
|
96
|
+
font-weight: 600;
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
transition: all 0.2s ease;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.btn:hover {
|
|
102
|
+
border-color: var(--accent-cyan);
|
|
103
|
+
box-shadow: 0 0 10px rgba(0, 240, 255, 0.2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.badge {
|
|
107
|
+
background: rgba(0, 240, 255, 0.1);
|
|
108
|
+
color: var(--accent-cyan);
|
|
109
|
+
border: 1px solid rgba(0, 240, 255, 0.3);
|
|
110
|
+
padding: 0.35rem 0.75rem;
|
|
111
|
+
border-radius: 20px;
|
|
112
|
+
font-size: 0.75rem;
|
|
113
|
+
font-weight: 700;
|
|
114
|
+
text-transform: uppercase;
|
|
115
|
+
letter-spacing: 0.05em;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.badge.connected {
|
|
119
|
+
background: rgba(0, 255, 136, 0.1);
|
|
120
|
+
color: var(--accent-green);
|
|
121
|
+
border-color: rgba(0, 255, 136, 0.3);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.badge.simulated {
|
|
125
|
+
background: rgba(255, 170, 0, 0.1);
|
|
126
|
+
color: var(--accent-amber);
|
|
127
|
+
border-color: rgba(255, 170, 0, 0.3);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.dashboard-grid {
|
|
131
|
+
display: grid;
|
|
132
|
+
grid-template-columns: repeat(4, 1fr);
|
|
133
|
+
gap: 1.25rem;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.metric-card {
|
|
137
|
+
background: var(--bg-surface);
|
|
138
|
+
border: 1px solid var(--border);
|
|
139
|
+
border-radius: 10px;
|
|
140
|
+
padding: 1.25rem;
|
|
141
|
+
display: flex;
|
|
142
|
+
flex-direction: column;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.card-label {
|
|
146
|
+
font-size: 0.7rem;
|
|
147
|
+
font-weight: 700;
|
|
148
|
+
letter-spacing: 0.05em;
|
|
149
|
+
color: var(--text-muted);
|
|
150
|
+
margin-bottom: 0.5rem;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.card-value {
|
|
154
|
+
font-size: 2rem;
|
|
155
|
+
font-weight: 800;
|
|
156
|
+
font-variant-numeric: tabular-nums;
|
|
157
|
+
line-height: 1.1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.unit {
|
|
161
|
+
font-size: 0.9rem;
|
|
162
|
+
color: var(--text-muted);
|
|
163
|
+
font-weight: 400;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.text-accent { color: var(--accent-cyan); }
|
|
167
|
+
.text-green { color: var(--accent-green); }
|
|
168
|
+
|
|
169
|
+
.card-hint {
|
|
170
|
+
font-size: 0.75rem;
|
|
171
|
+
color: var(--text-muted);
|
|
172
|
+
margin-top: 0.4rem;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.panel {
|
|
176
|
+
background: var(--bg-surface);
|
|
177
|
+
border: 1px solid var(--border);
|
|
178
|
+
border-radius: 12px;
|
|
179
|
+
padding: 1.25rem;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.radar-panel {
|
|
183
|
+
grid-column: span 1;
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.table-panel {
|
|
189
|
+
grid-column: span 3;
|
|
190
|
+
display: flex;
|
|
191
|
+
flex-direction: column;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.panel-header {
|
|
195
|
+
display: flex;
|
|
196
|
+
justify-content: space-between;
|
|
197
|
+
align-items: center;
|
|
198
|
+
margin-bottom: 1rem;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.panel-header h2 {
|
|
202
|
+
font-size: 0.95rem;
|
|
203
|
+
font-weight: 700;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.panel-header .hint,
|
|
207
|
+
.panel-header .counter-badge {
|
|
208
|
+
font-size: 0.75rem;
|
|
209
|
+
color: var(--text-muted);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.radar-container {
|
|
213
|
+
display: flex;
|
|
214
|
+
justify-content: center;
|
|
215
|
+
align-items: center;
|
|
216
|
+
padding: 1rem 0;
|
|
217
|
+
flex-grow: 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#radarCanvas {
|
|
221
|
+
background: #07090e;
|
|
222
|
+
border-radius: 50%;
|
|
223
|
+
border: 2px solid var(--border);
|
|
224
|
+
box-shadow: 0 0 30px rgba(0, 240, 255, 0.08);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.table-scroll {
|
|
228
|
+
overflow-y: auto;
|
|
229
|
+
max-height: 380px;
|
|
230
|
+
border: 1px solid var(--border);
|
|
231
|
+
border-radius: 8px;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.tag-table {
|
|
235
|
+
width: 100%;
|
|
236
|
+
border-collapse: collapse;
|
|
237
|
+
font-size: 0.8rem;
|
|
238
|
+
text-align: left;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.tag-table th {
|
|
242
|
+
background: var(--bg-surface-elevated);
|
|
243
|
+
padding: 0.75rem 1rem;
|
|
244
|
+
font-size: 0.7rem;
|
|
245
|
+
color: var(--text-muted);
|
|
246
|
+
letter-spacing: 0.05em;
|
|
247
|
+
position: sticky;
|
|
248
|
+
top: 0;
|
|
249
|
+
z-index: 10;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.tag-table td {
|
|
253
|
+
padding: 0.75rem 1rem;
|
|
254
|
+
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
|
255
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.tag-row.newly-added {
|
|
259
|
+
animation: highlightRow 1.2s ease-out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@keyframes highlightRow {
|
|
263
|
+
0% { background-color: rgba(0, 240, 255, 0.25); }
|
|
264
|
+
100% { background-color: transparent; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.dir-badge {
|
|
268
|
+
display: inline-block;
|
|
269
|
+
padding: 0.15rem 0.5rem;
|
|
270
|
+
border-radius: 4px;
|
|
271
|
+
font-size: 0.7rem;
|
|
272
|
+
font-weight: 700;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.dir-in {
|
|
276
|
+
background: rgba(0, 255, 136, 0.15);
|
|
277
|
+
color: var(--accent-green);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.dir-out {
|
|
281
|
+
background: rgba(0, 240, 255, 0.15);
|
|
282
|
+
color: var(--accent-cyan);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.rssi-bar-container {
|
|
286
|
+
display: flex;
|
|
287
|
+
align-items: center;
|
|
288
|
+
gap: 0.5rem;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.rssi-fill {
|
|
292
|
+
height: 6px;
|
|
293
|
+
background: var(--accent-cyan);
|
|
294
|
+
border-radius: 3px;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@media (max-width: 1024px) {
|
|
298
|
+
.dashboard-grid {
|
|
299
|
+
grid-template-columns: 1fr;
|
|
300
|
+
}
|
|
301
|
+
.radar-panel, .table-panel {
|
|
302
|
+
grid-column: span 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
package/test/cli.test.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const testAppDir = path.join(__dirname, 'test-output-app');
|
|
6
|
+
|
|
7
|
+
console.log('Testing create-autoid-app CLI scaffolding...');
|
|
8
|
+
|
|
9
|
+
// Clean up previous test if exists
|
|
10
|
+
if (fs.existsSync(testAppDir)) {
|
|
11
|
+
fs.rmSync(testAppDir, { recursive: true, force: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Run CLI with args: [projectName, templateKey]
|
|
15
|
+
const binPath = path.join(__dirname, '..', 'bin', 'index.js');
|
|
16
|
+
execSync(`node "${binPath}" "${testAppDir}" web-radar-dashboard`, { stdio: 'inherit' });
|
|
17
|
+
|
|
18
|
+
// Assertions
|
|
19
|
+
const expectedFiles = [
|
|
20
|
+
path.join(testAppDir, 'package.json'),
|
|
21
|
+
path.join(testAppDir, 'index.html'),
|
|
22
|
+
path.join(testAppDir, 'src', 'main.js'),
|
|
23
|
+
path.join(testAppDir, 'src', 'style.css'),
|
|
24
|
+
path.join(testAppDir, 'README.md')
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
for (const f of expectedFiles) {
|
|
28
|
+
if (!fs.existsSync(f)) {
|
|
29
|
+
console.error(`❌ Expected file not found: ${f}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Clean up test output
|
|
35
|
+
fs.rmSync(testAppDir, { recursive: true, force: true });
|
|
36
|
+
|
|
37
|
+
console.log('✅ All CLI Scaffolding tests passed successfully!');
|
package/README.md
DELETED