diff --git a/webui/web/css/main.css b/webui/web/css/main.css index 4c5d0ab..1e2627c 100644 --- a/webui/web/css/main.css +++ b/webui/web/css/main.css @@ -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 { diff --git a/webui/web/index.html b/webui/web/index.html index 21d573d..53ae618 100644 --- a/webui/web/index.html +++ b/webui/web/index.html @@ -643,6 +643,9 @@ + + + diff --git a/webui/web/js/api/probe.js b/webui/web/js/api/probe.js new file mode 100644 index 0000000..dc72129 --- /dev/null +++ b/webui/web/js/api/probe.js @@ -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} 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(); + } +} diff --git a/webui/web/js/main.js b/webui/web/js/main.js index fa2fccf..14978c8 100644 --- a/webui/web/js/main.js +++ b/webui/web/js/main.js @@ -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) { diff --git a/webui/web/js/ui/modal.js b/webui/web/js/ui/modal.js new file mode 100644 index 0000000..96f0693 --- /dev/null +++ b/webui/web/js/ui/modal.js @@ -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; +}