Integrate probe endpoint into frontend

- Add ProbeAPI client (js/api/probe.js)
- Add reusable modal component (js/ui/modal.js) with overlay, animations
- Call GET /api/v1/probe after Check Address click
- Auto-fill Camera Model with vendor from ARP/OUI lookup
- Show modal on unreachable device with Change IP / Continue Anyway buttons
- Add modal CSS styles matching existing dark theme
This commit is contained in:
eduard256
2026-03-16 20:05:00 +00:00
parent 833da5cf48
commit fe93aa329c
5 changed files with 261 additions and 1 deletions
+64
View File
@@ -1093,6 +1093,70 @@ body {
display: none;
}
/* ===== MODAL ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
opacity: 0;
transition: opacity var(--transition-base);
padding: var(--space-4);
}
.modal-overlay.show {
opacity: 1;
}
.modal-overlay.hidden {
display: none;
}
.modal {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: var(--space-8);
max-width: 400px;
width: 100%;
box-shadow: var(--shadow-lg);
transform: scale(0.95);
transition: transform var(--transition-base);
text-align: center;
}
.modal-overlay.show .modal {
transform: scale(1);
}
.modal-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--error);
margin-bottom: var(--space-4);
}
.modal-message {
font-size: var(--text-base);
color: var(--text-secondary);
margin-bottom: var(--space-8);
line-height: 1.6;
}
.modal-actions {
display: flex;
gap: var(--space-3);
}
.modal-actions .btn {
flex: 1;
padding: var(--space-4);
}
/* ===== ANIMATIONS ===== */
@keyframes fadeIn {
from {
+3
View File
@@ -643,6 +643,9 @@
</div>
</div>
<!-- Modal -->
<div id="modal-overlay" class="modal-overlay hidden"></div>
<!-- Toast Notification -->
<div id="toast" class="toast hidden"></div>
+24
View File
@@ -0,0 +1,24 @@
export class ProbeAPI {
constructor(baseURL = '') {
this.baseURL = baseURL;
}
/**
* Probe a device at the given IP address.
* Returns device info: reachable status, vendor, hostname, mDNS data.
* @param {string} ip - IP address to probe
* @returns {Promise<Object>} Probe response
*/
async probe(ip) {
const response = await fetch(
`${this.baseURL}api/v1/probe?ip=${encodeURIComponent(ip)}`
);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
return await response.json();
}
}
+87 -1
View File
@@ -1,5 +1,6 @@
import { CameraSearchAPI } from './api/camera-search.js';
import { StreamDiscoveryAPI } from './api/stream-discovery.js';
import { ProbeAPI } from './api/probe.js';
import { MockCameraAPI } from './mock/mock-camera-api.js';
import { MockStreamAPI } from './mock/mock-stream-api.js';
import { SearchForm } from './ui/search-form.js';
@@ -7,6 +8,7 @@ import { StreamList } from './ui/stream-list.js';
import { ConfigPanel } from './ui/config-panel.js';
import { FrigateGenerator } from './config-generators/frigate/index.js';
import { showToast } from './utils/toast.js';
import { showModal } from './ui/modal.js';
class StrixApp {
constructor() {
@@ -29,6 +31,9 @@ class StrixApp {
this.streamAPI = new StreamDiscoveryAPI();
}
this.probeAPI = new ProbeAPI();
this.probeResult = null;
this.searchForm = new SearchForm();
this.streamList = new StreamList();
this.configPanel = new ConfigPanel();
@@ -170,7 +175,88 @@ class StrixApp {
document.getElementById('address-validated').value = address;
}
this.showScreen('config');
// Extract IP for probe (from full URL or raw input)
const probeIP = this.extractIPForProbe(address);
// Probe the device before proceeding
const btn = document.getElementById('btn-check-address');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Checking...';
try {
this.probeResult = await this.probeAPI.probe(probeIP);
if (this.probeResult.reachable) {
// Auto-fill vendor into Camera Model if found
if (this.probeResult.probes.arp && this.probeResult.probes.arp.vendor) {
const modelInput = document.getElementById('camera-model');
if (!modelInput.disabled && !modelInput.value) {
modelInput.value = this.probeResult.probes.arp.vendor;
}
}
this.showScreen('config');
} else {
// Device unreachable -- show modal
const result = await showModal({
title: 'Device Unreachable',
message: `The device at ${probeIP} is not responding. It may be offline, on a different network, or the IP address may be incorrect.`,
buttons: [
{ id: 'change', label: 'Change IP', style: 'primary' },
{ id: 'continue', label: 'Continue Anyway', style: 'outline' }
]
});
if (result === 'continue') {
this.showScreen('config');
} else {
// 'change' or null (overlay click) -- stay on address screen
input.focus();
input.select();
}
}
} catch (error) {
// Network/server error -- show modal
const result = await showModal({
title: 'Connection Error',
message: `Could not check the device: ${error.message}`,
buttons: [
{ id: 'change', label: 'Change IP', style: 'primary' },
{ id: 'continue', label: 'Continue Anyway', style: 'outline' }
]
});
if (result === 'continue') {
this.showScreen('config');
} else {
input.focus();
}
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
/**
* Extract IP address from input for probe API call.
* Handles plain IPs and full URLs like rtsp://user:pass@192.168.1.50/stream
*/
extractIPForProbe(address) {
if (this.isFullURL(address)) {
try {
const urlObj = new URL(address);
return urlObj.hostname;
} catch (e) {
return address;
}
}
// Remove port if present (e.g., "192.168.1.50:554")
const colonIndex = address.lastIndexOf(':');
if (colonIndex > 0) {
return address.substring(0, colonIndex);
}
return address;
}
isFullURL(str) {
+83
View File
@@ -0,0 +1,83 @@
/**
* Simple modal dialog component.
* Shows a centered card with title, message, and configurable buttons.
* Returns a Promise that resolves with the clicked button's id.
*
* Usage:
* const result = await showModal({
* title: 'Device Unreachable',
* message: 'This IP is not responding.',
* buttons: [
* { id: 'change', label: 'Change IP', style: 'primary' },
* { id: 'continue', label: 'Continue Anyway', style: 'outline' }
* ]
* });
*/
let currentResolve = null;
export function showModal({ title, message, buttons }) {
return new Promise((resolve) => {
currentResolve = resolve;
const overlay = document.getElementById('modal-overlay');
// Clear previous content safely
overlay.replaceChildren();
// Build modal DOM using safe DOM methods
const modal = document.createElement('div');
modal.className = 'modal';
const titleEl = document.createElement('div');
titleEl.className = 'modal-title';
titleEl.textContent = title;
modal.appendChild(titleEl);
const messageEl = document.createElement('div');
messageEl.className = 'modal-message';
messageEl.textContent = message;
modal.appendChild(messageEl);
const actionsEl = document.createElement('div');
actionsEl.className = 'modal-actions';
buttons.forEach(btnConfig => {
const btn = document.createElement('button');
btn.className = `btn btn-${btnConfig.style || 'outline'}`;
btn.textContent = btnConfig.label;
btn.addEventListener('click', () => {
hideModal();
resolve(btnConfig.id);
});
actionsEl.appendChild(btn);
});
modal.appendChild(actionsEl);
overlay.appendChild(modal);
// Close on overlay click (outside modal)
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
hideModal();
resolve(null);
}
});
// Show with animation
overlay.classList.remove('hidden');
requestAnimationFrame(() => {
overlay.classList.add('show');
});
});
}
export function hideModal() {
const overlay = document.getElementById('modal-overlay');
overlay.classList.remove('show');
setTimeout(() => {
overlay.classList.add('hidden');
overlay.replaceChildren();
}, 200);
currentResolve = null;
}