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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user