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;
|
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 ===== */
|
/* ===== ANIMATIONS ===== */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
|
|||||||
@@ -643,6 +643,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div id="modal-overlay" class="modal-overlay hidden"></div>
|
||||||
|
|
||||||
<!-- Toast Notification -->
|
<!-- Toast Notification -->
|
||||||
<div id="toast" class="toast hidden"></div>
|
<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CameraSearchAPI } from './api/camera-search.js';
|
import { CameraSearchAPI } from './api/camera-search.js';
|
||||||
import { StreamDiscoveryAPI } from './api/stream-discovery.js';
|
import { StreamDiscoveryAPI } from './api/stream-discovery.js';
|
||||||
|
import { ProbeAPI } from './api/probe.js';
|
||||||
import { MockCameraAPI } from './mock/mock-camera-api.js';
|
import { MockCameraAPI } from './mock/mock-camera-api.js';
|
||||||
import { MockStreamAPI } from './mock/mock-stream-api.js';
|
import { MockStreamAPI } from './mock/mock-stream-api.js';
|
||||||
import { SearchForm } from './ui/search-form.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 { ConfigPanel } from './ui/config-panel.js';
|
||||||
import { FrigateGenerator } from './config-generators/frigate/index.js';
|
import { FrigateGenerator } from './config-generators/frigate/index.js';
|
||||||
import { showToast } from './utils/toast.js';
|
import { showToast } from './utils/toast.js';
|
||||||
|
import { showModal } from './ui/modal.js';
|
||||||
|
|
||||||
class StrixApp {
|
class StrixApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -29,6 +31,9 @@ class StrixApp {
|
|||||||
this.streamAPI = new StreamDiscoveryAPI();
|
this.streamAPI = new StreamDiscoveryAPI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.probeAPI = new ProbeAPI();
|
||||||
|
this.probeResult = null;
|
||||||
|
|
||||||
this.searchForm = new SearchForm();
|
this.searchForm = new SearchForm();
|
||||||
this.streamList = new StreamList();
|
this.streamList = new StreamList();
|
||||||
this.configPanel = new ConfigPanel();
|
this.configPanel = new ConfigPanel();
|
||||||
@@ -170,7 +175,88 @@ class StrixApp {
|
|||||||
document.getElementById('address-validated').value = address;
|
document.getElementById('address-validated').value = address;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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');
|
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) {
|
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