Shared Services and Reusable Components
Shared utilities, services, and reusable components in zmNinjaNg, with their consumers.
Shared Services (lib/)
The lib/ directory contains platform-agnostic utilities.
Logger (lib/logger.ts)
Structured logging system with sanitization and component-specific helpers.
Features:
Log levels: DEBUG, INFO, WARN, ERROR, NONE
Automatic sanitization of passwords, tokens, and sensitive data
Component-specific logger methods (e.g.,
log.api(),log.profile(),log.download())Centralized log storage for debug UI (
/logspage)
Implementation:
import { log, LogLevel } from '../lib/logger';
// Basic logging
log.info('User logged in', { username: 'john' });
log.error('Failed to fetch', { endpoint: '/api/monitors' }, error);
// Component-specific (preferred)
log.api('Fetching monitors', LogLevel.INFO, { endpoint: '/monitors.json' });
log.download('Download started', LogLevel.INFO, { filename: 'video.mp4' });
log.profileService('Switching profile', LogLevel.INFO, { from: 'A', to: 'B' });
Available Component Loggers:
api,app,auth,crypto,dashboard,discovery,downloaderrorBoundary,eventCard,eventDetail,eventMontagehttp,imageError,kiosk,monitor,monitorCard,monitorDetail,montageMonitornavigation,notificationHandler,notifications,notificationSettingsprofile,profileForm,profileService,profileSwitcherpush,queryCache,secureImage,secureStorage,server,sslTrust,time,timelinevideoMarkers,videoPlayer,zmsEventPlayer
The list is the source of truth in app/src/lib/logger.ts
(componentLoggers array). Adding a new logger means appending to
that array and to the matching Logger class field.
Used By: Entire application (all components, stores, API functions)
HTTP Client (lib/http.ts)
Platform-agnostic HTTP request abstraction that works on Web, iOS, Android, and Desktop.
Features:
Automatic platform detection (Capacitor, Electron, or Web)
CORS handling via proxy in development
Request/response logging via logger
Token injection for authentication
Support for multiple response types (json, blob, arraybuffer, text)
Implementation:
import { httpGet, httpPost, httpPut, httpDelete } from '../lib/http';
// GET request
const data = await httpGet<MonitorsResponse>('/api/monitors.json');
// POST with body
await httpPost('/api/states/change.json', {
monitorId: '1',
newState: 'Alert'
});
// With auth token
await httpGet('/api/events.json', {
token: accessToken,
params: { limit: 50 }
});
// Blob response (for downloads)
const blob = await httpGet<Blob>('/video.mp4', {
responseType: 'blob'
});
Platform Implementations:
Web: Uses
fetch()with standard CORS handlingMobile (Capacitor): Uses
CapacitorHttpfor native networkingDesktop (Electron): Uses Chromium
fetch()via Electron’s renderer
SSL Trust: On mobile, the native Capacitor plugin handles SSL trust (see SSL Trust section below). On Electron desktop, the user must add the CA to the system trust store.
Used By: API functions (api/), download utilities, all network
requests
SSL Trust (lib/ssl-trust.ts)
Controls whether the app accepts self-signed/untrusted HTTPS certificates
using TOFU (Trust On First Use) certificate pinning. The setting is
profile-scoped (allowSelfSignedCerts + trustedCertFingerprint in
ProfileSettings) and disabled by default.
TOFU Flow:
User enables self-signed certs and connects to a server
App fetches the server’s TLS certificate via
getServerCertFingerprint()A dialog (
CertTrustDialog) shows the cert’s SHA-256 fingerprintIf the user accepts, the fingerprint is stored in
ProfileSettings.trustedCertFingerprintAll subsequent connections validate the server cert against the stored fingerprint, mismatches are rejected
Platform Implementations:
Mobile (iOS/Android): Uses a custom Capacitor plugin (
SSLTrust) registered insrc/plugins/ssl-trust/. On Android,onReceivedSslErrorextracts the cert viaSslCertificate.saveState(), computes SHA-256, and callsproceed()only on fingerprint match, never without validation. The WebView handler is only installed when a fingerprint is set (viasetTrustedFingerprint()). HTTP requests use aTrustManagerthat validates fingerprints. On iOS, bothURLProtocolandWKNavigationDelegatevalidate cert fingerprints via CommonCrypto SHA-256.Desktop (Electron) and Web: No-op (Chromium enforces certificate validation). Users must add the CA to the system trust store.
Plugin Methods:
enable()/disable(): activate/deactivate the TrustManager (HTTP requests). Does not install the WebView handler.setTrustedFingerprint({ fingerprint }): pass the pinned fingerprint. Installs the WebView SSL handler only when fingerprint is non-null.getServerCertFingerprint({ url }): fetches the server’s leaf certificate and returns its SHA-256 fingerprint, subject, issuer, and expiry.
Implementation:
import { applySSLTrustSetting, getServerCertFingerprint } from '../lib/ssl-trust';
// Enable with fingerprint (normal operation)
await applySSLTrustSetting(true, storedFingerprint);
// Fetch cert for TOFU dialog
const certInfo = await getServerCertFingerprint('https://zm.example.com');
// certInfo.fingerprint = "AB:CD:12:..."
// Enable trust-all for HTTP only (no WebView handler, used during cert fetch)
await applySSLTrustSetting(true);
Bootstrap Order: bootstrapSSLTrust() in stores/profile-bootstrap.ts
runs before bootstrapAuth(). If allowSelfSignedCerts is true but
trustedCertFingerprint is null (upgrade migration), it fetches the cert
and signals the UI via lib/cert-trust-event.ts to show the trust dialog
in AppLayout.
Key Files:
lib/ssl-trust.ts: JS interfacelib/cert-trust-event.ts: event bridge for bootstrap-to-UI TOFU dialogplugins/ssl-trust/: Capacitor plugin definitionscomponents/CertTrustDialog.tsx: trust dialog componentandroid/.../SSLTrustPlugin.java: Android native implementationios/.../SSLTrustPlugin.swift: iOS native implementation
Used By: stores/profile-bootstrap.ts, pages/ProfileForm.tsx,
components/settings/ConnectionSettings.tsx,
components/layout/AppLayout.tsx (migration dialog)
Discovery (lib/discovery.ts)
ZoneMinder server discovery utility that probes for API endpoints and derives connection URLs.
Features:
Automatic HTTPS/HTTP fallback for scheme-less URLs
Probes
/zm/apiand/apipaths to find API endpointDerives
portalUrlandcgiUrlfrom confirmed API locationOptional authentication to fetch accurate
ZM_PATH_ZMSfrom server configCancellable via AbortSignal
Skips redundant probes on connection errors (faster failure)
Implementation:
import { discoverZoneminder, DiscoveryError } from '../lib/discovery';
// Basic discovery (no auth)
const result = await discoverZoneminder('192.168.1.100');
// Returns: { portalUrl, apiUrl, cgiUrl }
// With credentials (fetches accurate ZMS path from server)
const result = await discoverZoneminder('myserver.com', {
username: 'admin',
password: 'secret'
});
// With cancellation support
const abortController = new AbortController();
try {
const result = await discoverZoneminder('192.168.1.100', {
signal: abortController.signal
});
} catch (error) {
if (error instanceof DiscoveryError && error.code === 'CANCELLED') {
console.log('Discovery was cancelled');
}
}
// Cancel from elsewhere
abortController.abort();
Error Codes:
API_NOT_FOUND- No ZoneMinder API found at any probed pathPORTAL_UNREACHABLE- Server completely unreachableCANCELLED- Discovery was cancelled via AbortSignalUNKNOWN- Unexpected error
A higher-level wrapper, discoverUrls, bundles the common call pattern
with iOS retry logic and an abort signal, so neither ProfileForm nor
Profiles need to duplicate that handling:
import { discoverUrls } from '../lib/discovery';
// Shared wrapper with iOS retry logic and abort signal
const result = await discoverUrls(portalUrl, {
credentials: { username, password },
signal: abortController.signal,
onClientCreated: (client) => setApiClient(client),
});
// Returns: { portalUrl, apiUrl, cgiUrl }
Used By: ProfileForm.tsx, Profiles.tsx (both call
discoverUrls for profile creation/editing)
Download Utilities (lib/download.ts)
Cross-platform file download with progress tracking and cancellation support.
Features:
Platform-specific implementations (Web, Mobile, Desktop)
Progress callbacks for UI updates
Cancellation via AbortSignal
Automatic file saving to appropriate locations
Mobile: Saves to Documents + Photo/Video library
Desktop: User selects save location
Web: Browser download
Implementation:
import { downloadFile, downloadSnapshot } from '../lib/download';
// Download a file with progress
const abortController = new AbortController();
await downloadFile('https://example.com/video.mp4', 'event-123.mp4', {
signal: abortController.signal,
onProgress: (progress) => {
console.log(`${progress.percentage}% - ${progress.loaded}/${progress.total} bytes`);
},
});
// Cancel download
abortController.abort();
// Download monitor snapshot
await downloadSnapshot(imageUrl, monitorName);
Platform Implementations:
Web: Blob + anchor download
Mobile: CapacitorHttp, Filesystem, then Media library
Desktop (Electron): Chromium download to user-selected path
Note: Mobile uses base64 directly (not Blob conversion) to avoid out-of-memory errors on large video files.
Used By: MonitorCard, EventDetail, EventCard, VideoPlayer
Proxy URL Utilities (lib/proxy-utils.ts)
Utilities for wrapping URLs with development proxy to handle CORS.
Features:
Automatically wraps external URLs with proxy in development mode
Preserves URLs in production
Platform-aware (only web development needs proxy)
Implementation:
import { wrapWithImageProxy, wrapWithImageProxyIfNeeded } from '../lib/proxy-utils';
// Always wrap if proxy is enabled
const proxiedUrl = wrapWithImageProxy('https://zm.example.com/image.jpg');
// → 'http://localhost:3001/image-proxy?url=https%3A%2F%2Fzm.example.com%2Fimage.jpg'
// Conditionally wrap (checks if URL is external)
const url = wrapWithImageProxyIfNeeded('https://zm.example.com/image.jpg');
Used By: API functions (monitors, events), download utilities, HTTP client
Server Resolver (lib/server-resolver.ts)
Maps ZoneMinder ServerId values to per-server URLs for multi-server routing. In single-server setups the map is empty and all lookups return profile defaults.
Key functions:
import {
buildServerMap,
resolveMonitorUrls,
getPortalUrlForMonitor,
getPortalUrlForEvent,
setServerMap,
} from '../lib/server-resolver';
// Build the map from /servers.json response
const serverMap = buildServerMap(servers);
// Each entry contains: recordingUrl, portalPath, apiBaseUrl
// Store in module-level cache (called during bootstrap)
setServerMap(serverMap);
// Resolve URLs for a monitor by its ServerId
const urls = resolveMonitorUrls(monitor.ServerId, serverMap, {
portalUrl: profile.portalUrl,
apiUrl: profile.apiUrl,
});
// Quick lookups for list renderers
const portalUrl = getPortalUrlForMonitor(monitor.ServerId, profile.portalUrl);
const eventPortalUrl = getPortalUrlForEvent(monitorId, monitors, profile.portalUrl);
Caching: The module maintains a module-level cache updated via
setServerMap() during bootstrap and cleared on profile switch.
React components access the cache reactively via useSyncExternalStore
through the useServerUrls hook.
Fallback: When no servers exist or a ServerId is not found in the map, all functions return the profile’s default URLs.
Used By: useServerUrls hook, useMonitorStream, Server page,
MonitorDetail, EventDetail
URL Builder (lib/url-builder.ts)
Centralized URL construction for ZoneMinder endpoints.
Features:
Stream URLs with authentication
Event image/video URLs
Control URLs for PTZ
Consistent parameter handling
Cache busting support
Multi-port routing via
applyMultiPort
Multi-port helper:
import { applyMultiPort } from '../lib/url-builder';
// Shared helper for per-monitor port routing
// Formula: port = minStreamingPort + parseInt(monitorId)
const url = applyMultiPort('https://zm.example.com/cgi-bin/nph-zms', '4', 7100);
// → URL with port 7104
All URL builder functions (getMonitorStreamUrl, getEventImageUrl,
getEventVideoUrl, getEventZmsUrl) accept optional
minStreamingPort and monitorId parameters. When both are
provided, the output URL’s port is rewritten using the multi-port
formula.
Implementation:
import {
getMonitorStreamUrl,
getEventImageUrl,
getEventVideoUrl
} from '../lib/url-builder';
// Monitor stream
const streamUrl = getMonitorStreamUrl(cgiUrl, monitorId, {
token: accessToken,
mode: 'jpeg',
maxfps: 10,
connkey: 12345,
});
// Event thumbnail
const imageUrl = getEventImageUrl(portalUrl, eventId, frameNumber, {
token: accessToken,
width: 320,
height: 240,
});
// Event video download
const videoUrl = getEventVideoUrl(portalUrl, eventId, {
token: accessToken,
format: 'mp4',
});
// With multi-port routing
const streamUrl = getMonitorStreamUrl(cgiUrl, monitorId, {
token: accessToken,
mode: 'jpeg',
connkey: 12345,
minStreamingPort: 7100,
monitorId: '4',
});
Used By: API functions (api/events.ts, api/monitors.ts),
hooks (useStreamLifecycle), and stream/playback components.
Event Icons (lib/event-icons.ts)
Maps event causes from ZoneMinder to Lucide icons for visual display.
Features:
Exact match for known causes (Motion, Alarm, Signal, Linked, etc.)
Prefix matching for cause variants (e.g., “Motion:All”, “Motion:Person” → Motion icon)
Fallback to Circle icon for unknown causes
Implementation:
import { getEventCauseIcon, hasSpecificCauseIcon } from '../lib/event-icons';
// Get icon component for a cause
const Icon = getEventCauseIcon('Motion'); // Returns Move icon
const Icon2 = getEventCauseIcon('Motion:Person'); // Also returns Move icon (prefix match)
const Icon3 = getEventCauseIcon('Unknown'); // Returns Circle icon (fallback)
// Check if cause has a specific (non-fallback) icon
const hasIcon = hasSpecificCauseIcon('Motion'); // true
const hasIcon2 = hasSpecificCauseIcon('Custom'); // false
Mapped Causes:
Motion→ Move iconAlarm→ Bell iconSignal→ Wifi iconLinked→ Link iconForced Web→ Hand iconContinuous→ Video icon
Used By: EventCard, event list components
Time Utilities (lib/time.ts)
Date/time formatting and timezone conversion for ZoneMinder API.
Features:
Server-compatible ISO format
Local datetime formatting for inputs
Timezone conversion
Duration formatting
Implementation:
import { formatForServer, formatLocalDateTime } from '../lib/time';
// For API requests (server timezone)
const serverTime = formatForServer(new Date());
// → '2024-01-10 15:30:45'
// For datetime-local inputs
const localTime = formatLocalDateTime(new Date());
// → '2024-01-10T15:30:45'
For user-facing date/time display, use useDateTimeFormat() (React) or
formatAppDate/formatAppTime/formatAppDateTime from
lib/format-date-time.ts outside React. These honor the per-profile
dateFormat / timeFormat settings. Never call date-fns format()
with hard-coded patterns for user-visible output.
Used By: API functions, Events page, filters, dashboard widgets
Crypto Utilities (lib/crypto.ts)
Encryption/decryption for secure password storage (web platform).
Features:
AES-256-GCM encryption
Secure key derivation
Browser SubtleCrypto API
Base64 encoding for storage
Implementation:
import { encrypt, decrypt } from '../lib/crypto';
// Encrypt, key is internal (derived once per app install and cached)
const encrypted = await encrypt('my-password');
// → Base64 string (IV + ciphertext)
// Decrypt
const password = await decrypt(encrypted);
// → 'my-password'
The encryption key is generated and persisted internally (no key parameter is
required at the call site). A decryptLegacy() helper exists for migrating
data encrypted with an older key derivation; new code should use decrypt().
Used By: ProfileService, secure storage (web fallback)
Secure Storage (lib/secureStorage.ts)
Platform-specific secure storage abstraction.
Features:
Mobile: Native keychain/keystore via
@aparajita/capacitor-secure-storageWeb: Encrypted localStorage via crypto utilities
Consistent API across platforms
Implementation:
import { saveSecure, getSecure, removeSecure } from '../lib/secureStorage';
// Save password
await saveSecure('password_profile_123', 'my-secure-password');
// Retrieve password
const password = await getSecure('password_profile_123');
// Delete password
await removeSecure('password_profile_123');
Used By: ProfileService (password management)
Platform Detection (lib/platform.ts)
Platform detection utilities.
Features:
Detects Electron (desktop), Capacitor (mobile), or Web
Proxy mode detection (development)
Consistent platform checks
Implementation:
import { Platform } from '../lib/platform';
if (Platform.isElectron) {
// Desktop-specific code
}
if (Platform.isNative) {
// Mobile-specific code
}
if (Platform.shouldUseProxy) {
// Wrap URLs with proxy
}
Used By: HTTP client, download utilities, proxy utilities, platform-specific features
App Version (lib/version.ts)
Exposes the app’s marketing version and build number.
The marketing version comes from package.json and is bumped only on a
prod release. The build number is the git commit count
(git rev-list --count HEAD), injected at build time as the
__BUILD_NUMBER__ compile-time constant by vite.config.ts. It
increases on every commit, so two builds that share a marketing version
are still distinguishable. Outside a git checkout the build number is
dev. vitest.config.ts defines __BUILD_NUMBER__ as test.
Public API:
import { getAppVersion, getBuildNumber, getFullVersion } from '../lib/version';
getAppVersion(); // "1.1.14"
getBuildNumber(); // "1509"
getFullVersion(); // "1.1.14 (1509)"
Used By: SidebarContent renders getFullVersion() (expanded) or
getAppVersion() (collapsed) in the sidebar footer.
Safe-Area Bootstrap (lib/safe-area-bootstrap.ts)
Mirrors iOS UIView.safeAreaInsets into --sai-top/--sai-right/
--sai-bottom/--sai-left CSS custom properties on
document.documentElement. Works around a bug in iOS WKWebView
(Capacitor 7, contentInset='never', viewport-fit=cover) where
env(safe-area-inset-*) reports stale values after rotation. On
Dynamic Island devices env(top) stays 0 in portrait, and
env(left)/env(right) keep landscape-derived values regardless of
orientation. The native SafeArea Capacitor plugin reads UIKit’s
source of truth and emits safeAreaInsetsChanged events that this
module applies to the CSS variables.
Public API:
export async function installSafeAreaBootstrap(): Promise<void>
Wiring: Called once at app startup, before React mounts:
// app/src/main.tsx
import { installSafeAreaBootstrap } from './lib/safe-area-bootstrap'
void installSafeAreaBootstrap();
createRoot(document.getElementById('root')!).render(
<StrictMode><App /></StrictMode>,
)
Platform behavior:
iOS (Capacitor): Imports
plugins/safe-area, callsSafeArea.getInsets()once at startup (initial paint), then subscribes tosafeAreaInsetsChangedto update the four--sai-*variables on every UIKit-reported change.Android (Capacitor): Early-returns. The Android WebView’s
env(safe-area-inset-*)resolves correctly, so the CSS fallback is used.Web and Electron: Early-returns. Same reasoning.
CSS usage: Reference the variables with the native env() as the
fallback so non-iOS platforms get the browser value:
padding-top: var(--sai-top, env(safe-area-inset-top));
Plugin shape: The Capacitor plugin is defined in
plugins/safe-area/ with getInsets() and the
safeAreaInsetsChanged event. Its TypeScript surface is in
plugins/safe-area/definitions.ts (the SafeAreaInsets and
SafeAreaPlugin types). The web stub never invokes the listener.
Used By: main.tsx (single callsite), CSS rules in index.css
and component styles that consume var(--sai-*).
API Validator (lib/api-validator.ts)
Zod-based runtime validation for API responses.
Features:
Type-safe runtime validation
Detailed error messages
Schema coercion (e.g., numbers as strings)
Implementation:
import { validateApiResponse } from '../lib/api-validator';
import { MonitorsResponseSchema } from '../api/types';
const response = await fetch('/api/monitors.json');
const data = await response.json();
// Validate and coerce types
const validated = validateApiResponse(MonitorsResponseSchema, data, {
endpoint: '/api/monitors.json',
method: 'GET',
});
Used By: All API functions in api/ directory
Grid Utils (lib/grid-utils.ts)
Grid layout calculations for montage views.
Features:
Responsive grid column calculations
Aspect ratio handling
Breakpoint support
Implementation:
import { calculateGridCols, calculateItemHeight } from '../lib/grid-utils';
const cols = calculateGridCols(viewportWidth, minCardWidth);
const height = calculateItemHeight(cardWidth, aspectRatio);
Used By: Montage page, EventMontage page, dashboard grid
Bandwidth Settings (lib/zmninja-ng-constants.ts)
Configurable polling and refresh intervals that adapt to the user’s
bandwidth mode (normal vs. low). Defined in the BandwidthSettings
interface and the BANDWIDTH_SETTINGS constant map.
Usage:
import { useBandwidthSettings } from '../hooks/useBandwidthSettings';
const bandwidth = useBandwidthSettings();
// Use in React Query
const { data } = useQuery({
queryKey: ['monitors'],
queryFn: getMonitors,
refetchInterval: bandwidth.monitorStatusInterval,
});
Available Properties:
monitorStatusInterval: Monitor status updatesalarmStatusInterval: Alarm state checkingconsoleEventsInterval: Event count refreshingeventsWidgetInterval: Dashboard events widgettimelineHeatmapInterval: Timeline/heatmap datadaemonCheckInterval: Server daemon healthsnapshotRefreshInterval: Snapshot image refreshzmsStatusInterval: ZMS playback status polling interval (normal: 3000 ms, low: 5000 ms). Used byZmsEventPlayerto poll the ZMS stream status (ZM_CMD.QUERY) for tracking playback position.imageScale: Image scaling percentageimageQuality: Image quality percentagestreamMaxFps: Maximum stream FPS
Adding a new property: Add the field to BandwidthSettings in
lib/zmninja-ng-constants.ts with values for both normal and
low modes, then consume it via useBandwidthSettings().
Used By: Dashboard widgets, monitor views, event player, montage, any component that polls or auto-refreshes
Monitor Rotation (lib/monitor-rotation.ts)
Utilities for handling monitor orientation and aspect ratio calculations.
Key Functions:
parseMonitorRotation(orientation): Parses monitor orientation string to degreesgetMonitorAspectRatio(width, height, orientation): Returns aspect ratio string accounting for rotationgetOrientedResolution(width, height, orientation): Returns orientedWxHstring (swaps dimensions for 90°/270° rotation)
Used By: MonitorDetail.tsx, EventDetail.tsx, useMontageGrid.ts
Event Utilities (lib/event-utils.ts)
Shared helpers for event and monitor grid calculations.
Key Functions:
getMaxColsForWidth(width, minWidth, margin): Calculate maximum grid columns for a given container widthgetMonitorDimensions(monitor, fallbackWidth, fallbackHeight): Extract monitor dimensions with fallbacks
Used By: EventListView.tsx, EventMontageView.tsx, useEventMontageGrid.ts
Monitor Filters (lib/filters.ts)
Pure functions that filter monitor lists. They take and return plain arrays, so they are easy to unit test.
Key Functions:
filterEnabledMonitors(monitors): Drop deleted monitorsfilterExcludedMonitors(monitors, excludedIds): Drop monitors whoseIdis inexcludedIds. Returns the input unchanged when the list is empty.filterMonitorsByGroup(monitors, groupMonitorIds): Keep only monitors in the given group
Usage:
import { filterExcludedMonitors } from '../lib/filters';
const visible = filterExcludedMonitors(monitors, ['3', '7']);
Used By: getMonitors in api/monitors.ts (per-profile monitor
exclusion).
Profile Settings Accessor (lib/profile-settings.ts)
Non-React accessors for the current profile’s settings. API modules and
other services run outside React and cannot use hooks, so these read the
profile-scoped settings through the Zustand getState() pattern.
Key Functions:
getExcludedMonitorIds(): Returns theexcludedMonitorIdsarray for the current profile, or an empty array when there is no current profile or the stores are not yet initialized.
Usage:
import { getExcludedMonitorIds } from '../lib/profile-settings';
const excluded = getExcludedMonitorIds();
The excludedMonitorIds setting itself is defined on ProfileSettings
in stores/settings.ts and written via updateProfileSettings. It
defaults to an empty array.
Used By: api/monitors.ts and api/events.ts to apply the
per-profile exclusion at the API boundary.
Group-Keyed Montage Settings (stores/settings.ts)
Montage layout state is scoped per monitor group inside a profile. A
profile can show all monitors or one selected group, and each group keeps
its own grid columns, hidden monitors, and saved layouts. Two
ProfileSettings fields hold this state:
montageByGroup: Record<string, MontageGroupLayout>;
eventMontageByGroup: Record<string, EventMontageGroupLayout>;
The map key is the group ID, or the ALL_GROUPS_KEY sentinel when no
group is selected:
export const ALL_GROUPS_KEY = '__all__';
The active key is selectedGroupId ?? ALL_GROUPS_KEY, where
selectedGroupId is the profile’s current group filter (null for
all monitors).
The bucket shapes are:
interface MontageGroupLayout {
workingLayout: Layout[];
savedLayouts: MontageSavedLayout[];
activeLayoutName: string | null;
gridCols: number;
hiddenMonitorIds: string[];
}
// Event montage is a uniform grid, so only the column count is scoped.
interface EventMontageGroupLayout {
gridCols: number;
}
DEFAULT_MONTAGE_GROUP_LAYOUT and DEFAULT_EVENT_MONTAGE_GROUP_LAYOUT
supply the values used when a group has no stored bucket. Both default
gridCols to 2; the montage default also has an empty
workingLayout, empty savedLayouts, activeLayoutName: null, and
empty hiddenMonitorIds. DEFAULT_SETTINGS starts both maps empty
({}), so a group’s bucket is created lazily on first write.
Reading and writing. Live montage components use the
useMontageGroupState() hook rather than touching montageByGroup
directly:
import { useMontageGroupState } from '../hooks/useMontageGroupState';
const { groupKey, bucket, update } = useMontageGroupState();
// bucket is the active group's MontageGroupLayout (defaults when absent)
update({ gridCols: 3, hiddenMonitorIds: ['12'] });
The hook resolves groupKey from useGroupFilter, reads the matching
bucket (falling back to DEFAULT_MONTAGE_GROUP_LAYOUT), and exposes
update as a partial patch. Internally it calls the store action
updateMontageGroupLayout(profileId, groupKey, patch), which merges the
patch into that group’s bucket.
Event montage columns are written through the matching store action:
const updateEventMontageGroupLayout = useSettingsStore(
(state) => state.updateEventMontageGroupLayout
);
updateEventMontageGroupLayout(profileId, groupKey, { gridCols: 4 });
Persist migration. The store is at version: 1 and registers
migrateSettings as its migrate callback. Persisted state from v0
held flat montage fields (montageLayouts, montageSavedLayouts,
montageActiveLayoutName, montageGridCols, montageGridRows,
montageHiddenMonitorIds, eventMontageGridCols,
eventMontageLayouts). The migration removes those fields from each
profile and seeds the ALL_GROUPS_KEY bucket from them. The old
montageLayouts.lg array becomes the new workingLayout; absent
values fall back to the defaults above. Profiles created after v1 skip
the migration and start with empty maps.
Dangling group filter self-heal. A persisted selectedGroupId can
point at a group that no longer exists on the server. useGroupFilter
resets selectedGroupId to null after a successful groups load when
the stored ID is not in the returned list. The reset is guarded so it does
not fire while the groups query is loading or has errored, which avoids
clearing a valid selection during a transient fetch failure.
Used By: useMontageGroupState (live montage pages and the grid
hook), the event montage column control, and the persist layer of
useSettingsStore.
Stream Lifecycle (hooks/useStreamLifecycle.ts)
Shared hook for ZMS stream connection key management and cleanup.
Features:
Generates unique connection keys per monitor
Sends CMD_QUIT before regenerating keys (prevents orphaned server streams)
Sends CMD_QUIT on component unmount
Aborts in-flight image loads on unmount
Accepts a logger function for component-specific logging
Implementation:
import { useStreamLifecycle } from '../hooks/useStreamLifecycle';
const { connKey } = useStreamLifecycle({
monitorId: monitor.Id,
portalUrl: profile.portalUrl,
accessToken: auth.accessToken,
viewMode: 'streaming', // CMD_QUIT only fires in streaming mode
mediaRef: imgRef,
logFn: log.montageMonitor,
});
Used By: useMonitorStream, MontageMonitor.tsx, MonitorWidget.tsx
Reusable UI Components
Located in src/components/ui/, these are primitive components used
throughout the app.
Button (ui/button.tsx)
Styled button component with variants.
Variants: default, destructive, outline, secondary, ghost, link
Sizes: default, sm, lg, icon
Usage:
<Button variant="destructive" size="sm" onClick={handleDelete}>
Delete
</Button>
Used By: All pages and components
Card (ui/card.tsx)
Container component for content sections.
Sub-components: Card, CardHeader, CardTitle, CardContent, CardFooter
Usage:
<Card>
<CardHeader>
<CardTitle>Monitor Name</CardTitle>
</CardHeader>
<CardContent>
<img src={streamUrl} />
</CardContent>
<CardFooter>
<Button>View Details</Button>
</CardFooter>
</Card>
Used By: MonitorCard, EventCard, Dashboard widgets, Settings pages
Dialog (ui/dialog.tsx)
Modal dialog for confirmations and forms.
Sub-components: Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
Usage:
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogDescription>
Are you sure you want to delete this event?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={handleConfirm}>Delete</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Used By: Event deletion, profile deletion, widget editing, PTZ presets
Popover (ui/popover.tsx)
Floating content container for filters and actions.
Sub-components: Popover, PopoverTrigger, PopoverContent
Usage:
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">
<Filter className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent>
{/* Filter controls */}
</PopoverContent>
</Popover>
Used By: Filters (Events, Monitors, Timeline), date range selectors
SecureImage (ui/secure-image.tsx)
Image component that handles authenticated requests.
Features:
Fetches images with credentials
Converts to blob URL
Automatic cleanup
Implementation:
<SecureImage
src="https://zm.example.com/protected-image.jpg"
alt="Monitor snapshot"
className="w-full h-auto"
/>
How it works: 1. Fetches image with credentials: 'include' 2.
Converts response to Blob 3. Creates local blob URL 4. Cleans up on
unmount
Used By: Components that need authenticated images (rare - most use stream URLs with tokens)
EventThumbnail (events/EventThumbnail.tsx)
Event thumbnail with a user-configurable fallback chain. Receives an
ordered list of candidate URLs (urls) and a stable cacheKey
(typically the event id). On <img> onError it advances to the
next URL. The image is rendered with opacity: 0 until onLoad
fires, so the browser never flashes its broken-image glyph while the
chain is walking. The winning index is kept in a session-scoped
Map<cacheKey, index> so list re-renders don’t re-probe the chain.
Props:
interface EventThumbnailProps {
urls: string[]; // candidate URLs in order
cacheKey: string; // stable per-event identifier
alt?: string;
objectFit?: CSSProperties['objectFit'];
className?: string;
}
Usage:
import { buildThumbnailChain } from '../../lib/thumbnail-chain';
import { useCurrentProfile } from '../../hooks/useCurrentProfile';
const { settings } = useCurrentProfile();
const urls = buildThumbnailChain(portalUrl, event.Id, settings.thumbnailFallbackChain, {
token: accessToken,
width,
height,
minStreamingPort,
monitorId: event.MonitorId,
});
<EventThumbnail urls={urls} cacheKey={event.Id} alt={event.Name} />
The chain itself comes from the per-profile thumbnailFallbackChain
setting (see stores/settings.ts). resolveFallbackFids and
buildThumbnailChain in lib/thumbnail-chain.ts translate the
setting into ordered fids and full URLs. Disabled entries and empty
custom rows are skipped.
Used By: EventCard (via EventListView and EventMontageView),
TimelineScrubber thumbnails, NotificationHistory. The EventDetail hero
poster and EventPreviewPopover pick the first fid from the resolved
chain instead of hardcoding snapshot/alarm.
VideoPlayer (ui/video-player.tsx)
HTML5 video wrapper with platform integration.
Features:
Autoplay control
Fullscreen support
Error handling
Play/pause callbacks
Usage:
<VideoPlayer
src={videoUrl}
autoplay={true}
onPlay={() => console.log('Playing')}
onPause={() => console.log('Paused')}
/>
Used By: EventDetail page (MP4 playback), MonitorDetail (live streams)
PasswordInput (ui/password-input.tsx)
Password input with show/hide toggle.
Features:
Eye icon toggle
Keyboard-accessible
Standard input props
Usage:
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
/>
Used By: ProfileForm, Login components
EmptyState (ui/empty-state.tsx)
Placeholder component for empty lists/states.
Features:
Icon support
Title and description
Optional action button
Usage:
<EmptyState
icon={<Inbox className="h-12 w-12" />}
title="No events found"
description="Try adjusting your filters"
action={
<Button onClick={clearFilters}>Clear Filters</Button>
}
/>
Used By: Events page, Monitors page, Dashboard (when no widgets)
PullToRefresh (ui/pull-to-refresh-indicator.tsx)
Visual indicator for pull-to-refresh gesture.
Features:
Platform-aware (mobile only)
Animated spinner
Progress indication
Usage:
import { usePullToRefresh } from '../hooks/usePullToRefresh';
const { containerRef, bind, isRefreshing, isPulling, pullDistance } =
usePullToRefresh({ onRefresh: () => refetch() });
<div ref={containerRef} {...bind()} className="overflow-y-auto h-full">
<PullToRefreshIndicator
isPulling={isPulling}
isRefreshing={isRefreshing}
pullDistance={pullDistance}
/>
{/* Content */}
</div>
The hook takes a destructured options object (onRefresh, optional
threshold, optional enabled) and gesture detection is wired through
@use-gesture/react’s useDrag. Spread the returned bind() props
onto the scroll container and attach containerRef so the hook can read
scrollTop to gate activation.
Used By: Events page, Monitors page, Montage page
QuickDateRangeButtons (ui/quick-date-range-buttons.tsx)
Quick date range selector buttons.
Features:
Predefined ranges (24h, 48h, 1wk, 2wk, 1mo)
Abbreviated labels with tooltips
Responsive (hides text on mobile)
Usage:
<QuickDateRangeButtons
onRangeSelect={({ start, end }) => {
setStartDate(start);
setEndDate(end);
}}
/>
Used By: Events filter, Timeline filter, Dashboard widgets
Select (ui/select.tsx)
Dropdown select component.
Sub-components: Select, SelectTrigger, SelectValue, SelectContent, SelectItem
Usage:
<Select value={value} onValueChange={setValue}>
<SelectTrigger>
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1">Option 1</SelectItem>
<SelectItem value="option2">Option 2</SelectItem>
</SelectContent>
</Select>
Used By: Settings pages, filters, dashboard widget config
Switch (ui/switch.tsx)
Toggle switch component.
Usage:
<Switch
checked={enabled}
onCheckedChange={setEnabled}
/>
Used By: Settings pages, filters (favorites toggle)
Badge (ui/badge.tsx)
Small status indicator.
Variants: default, secondary, destructive, outline
Usage:
<Badge variant="destructive">Offline</Badge>
<Badge>5 events</Badge>
Used By: MonitorCard (status), EventCard (alarm frames), navigation (counts)
Progress (ui/progress.tsx)
Progress bar component.
Usage:
<Progress value={percentage} max={100} />
Used By: Background task drawer (download progress)
Shared Hooks (hooks/)
useEventFilters (hooks/useEventFilters.ts)
Manages event filter state with auto-save persistence. Filter selections are saved to the settings store immediately via wrapped setters, no “Apply” button needed for persistence.
Key concepts:
Local state (
selectedMonitorIds,selectedTagIds, etc.) drives the UI and thefiltersobject for API queriesWrapped setters (e.g.
setSelectedTagIds) update local state AND callsaveFilterField()to write to the settings storeRestore effect reads from settings on mount/profile change using raw
_set*functions (bypasses save wrappers to avoid loops)ALL_TAGS_FILTER_IDsentinel ('__all_tags__') means “show events with any tag”, mutually exclusive with individual tag selectionsonlyDetectedObjectsflag addsnotesRegexp: 'detected:'to the API filter (server-side Notes REGEXP filter)The “Filter” button syncs state to URL params for deep linking
Used By: Events page, EventsFilterPopover
Event Notes Display
ZoneMinder stores object detection results in the Notes field
(e.g. detected:car| Motion: All), not in Cause. The Notes
field is displayed in EventCard, EventMontageView, EventDetail, and
the dashboard EventsWidget. Everything after | is stripped in
the display (redundant with Cause) but preserved in the title
attribute for hover.
Sidebar Navigation Reorder
Users can reorder sidebar menu items via an edit mode (pencil icon
in the sidebar). Order is saved per profile in
ProfileSettings.sidebarNavOrder (array of route paths). The
SidebarContent component (components/layout/SidebarContent.tsx)
sorts navItems by saved order using a useMemo. Reorder uses
pointer events for drag-and-drop with live swap on midpoint crossing.
AppLayout.tsx is a thin shell that composes SidebarContent
(navigation, reorder, user controls) and LanguageSwitcher
(self-contained language dropdown). The bulk of sidebar logic lives in
SidebarContent.tsx.
Reusable Domain Components
Located in src/components/, organized by domain.
MonitorFilterPopover (filters/MonitorFilterPopover.tsx)
Monitor selection filter with “All Monitors” toggle.
Features:
Select individual monitors
“All Monitors” checkbox
Search/filter monitors
Used in multiple contexts (Events, Timeline, Dashboard)
Usage:
<MonitorFilterPopoverContent
monitors={monitors}
selectedMonitorIds={selectedMonitorIds}
onSelectionChange={setSelectedMonitorIds}
idPrefix="events" // for unique checkbox IDs
/>
Used By: Events page, Timeline page, Dashboard widget config
EventsFilterPopover (events/EventsFilterPopover.tsx)
Event filtering UI (extracted from Events page).
Features:
Monitor selection via MonitorFilterPopover
Favorites-only toggle
Date range inputs
Quick date range buttons
Apply/Clear actions
Usage:
<EventsFilterPopover
monitors={monitors}
selectedMonitorIds={selectedMonitorIds}
onMonitorSelectionChange={setSelectedMonitorIds}
favoritesOnly={favoritesOnly}
onFavoritesOnlyChange={setFavoritesOnly}
startDateInput={startDate}
onStartDateChange={setStartDate}
endDateInput={endDate}
onEndDateChange={setEndDate}
onQuickRangeSelect={({ start, end }) => {
setStartDate(formatLocalDateTime(start));
setEndDate(formatLocalDateTime(end));
}}
onApplyFilters={applyFilters}
onClearFilters={clearFilters}
/>
Used By: Events page
BackgroundTaskDrawer (BackgroundTaskDrawer.tsx)
Drawer UI for background tasks (downloads, uploads, syncs).
Features:
Task progress bars
Cancellation support
Auto-expand/collapse states
Completion badge
States:
Hidden: No tasks
Expanded: Shows progress bars
Collapsed: Thin bar at bottom
Badge: Floating count badge
Usage:
// Automatically rendered in App.tsx layout
// Controlled by background task store
// Adding a task
const taskStore = useBackgroundTasks.getState();
const taskId = taskStore.addTask({
type: 'download',
metadata: { title: 'Video.mp4', description: 'Event 123' },
cancelFn: () => abortController.abort(),
});
// Update progress
taskStore.updateProgress(taskId, percentage, bytesProcessed);
// Complete
taskStore.completeTask(taskId);
Used By: Download functions, upload functions (future)
Usage Matrix
This table shows which components/pages use which shared services:
Service/Utility |
Used By |
|---|---|
logger |
All components, stores, API functions |
http |
All API functions, download utilities |
download |
MonitorCard, EventDetail, EventCard, VideoPlayer |
proxy-utils |
API functions (monitors, events), download, http |
url-builder |
API functions (events, monitors), useStreamLifecycle |
time |
API functions, Events page, Timeline, Dashboard widgets |
crypto |
ProfileService, secure storage (web) |
secureStorage |
ProfileService |
platform |
HTTP client, download, proxy utilities |
api-validator |
All API functions |
grid-utils |
Montage, EventMontage, Dashboard |
UI Component |
Used By |
|---|---|
Button |
All pages and components |
Card |
MonitorCard, EventCard, Dashboard widgets, Settings |
Dialog |
Event deletion, Profile deletion, Widget editing, PTZ presets |
Popover |
Filters (Events, Monitors, Timeline), date selectors |
SecureImage |
(Rare - authenticated images) |
VideoPlayer |
EventDetail, MonitorDetail |
PasswordInput |
ProfileForm |
EmptyState |
Events, Monitors, Dashboard |
PullToRefresh |
Events, Monitors, Montage |
QuickDateRangeButtons |
Events filter, Timeline, Dashboard widgets |
Select |
Settings pages, filters, widget config |
Switch |
Settings pages, favorites toggle |
Badge |
MonitorCard, EventCard, navigation |
Progress |
BackgroundTaskDrawer |
Domain Component |
Used By |
|---|---|
MonitorFilterPopover |
Events page, Timeline page, Dashboard widget config |
EventsFilterPopover |
Events page |
BackgroundTaskDrawer |
App layout (auto-rendered) |
Adding New Shared Services
When creating a new shared utility, follow these guidelines:
1. Choose the Right Location
lib/- Pure utilities (no React, no stores)hooks/- React-specific logicservices/- Platform-specific bridges (Capacitor plugins)components/ui/- Primitive UI componentscomponents/domain/- Domain-specific reusable components
2. Document Usage
Update this file with:
Description of the utility
Code examples
Platform considerations
List of consumers
3. Follow Patterns
Look at existing utilities for patterns:
Consistent error handling
Logging via component-specific loggers
Platform detection where needed
TypeScript types exported
4. Test
All shared utilities should have unit tests in __tests__/
subdirectory.
Navigation Service (lib/navigation.ts)
Bridges non-React code (services, push notification handlers) with React
Router. Services cannot call useNavigate() directly, so they emit
navigation events through this singleton and NotificationHandler
listens and forwards them to the router.
API:
import { navigationService } from '../lib/navigation';
// Navigate to an event (e.g., from push notification tap)
navigationService.navigateToEvent(eventId, {
from: '/monitors', // back-button destination
fromNotification: true, // skip lastRoute persistence
});
// Generic navigation
navigationService.navigate('/monitors/5');
// Listen in a React component
useEffect(() => {
const unsubscribe = navigationService.addListener((event) => {
navigate(event.path, { replace: event.replace, state: event.state });
});
return unsubscribe;
}, [navigate]);
NavigationState properties:
from: explicit back-button destination (read by EventDetail/MonitorDetail vialocation.state?.from)fromNotification: whentrue, AppLayout skips saving the route aslastRouteso the app does not reopen to a transient event playback screen
Used By: pushNotifications.ts, NotificationHandler.tsx
Notification Services (services/)
The notification system spans three services that handle different delivery mechanisms:
services/notifications.ts: WebSocket connection to ZoneMinder Event
Server (ES mode). Handles real-time alarm events via zmeventnotification.pl.
Singleton via
getNotificationService()Exponential backoff reconnection with jitter (2s base, 2min cap)
intentionalDisconnectflag prevents reconnect after user-initiated disconnect; network failures always retrycheckAlive(timeoutMs)liveness probe used on app resume and tab visibility changereconnectNow()for immediate reconnect on network restore60-second keepalive ping
reconnectAttemptsresets only after successful authentication
services/pushNotifications.ts: FCM push notification handling for
iOS and Android.
Singleton via
getPushService()Requests permission, obtains FCM token, registers with ZM server
In ES mode: registers token via WebSocket; in Direct mode: via REST API
Foreground notifications are processed and added to the notification store (but ignored if WebSocket is already connected, to avoid duplicates)
Handles notification tap to navigate to event detail
services/eventPoller.ts: Polls ZM events API for new events in
Direct notification mode on desktop/web.
Singleton via
getEventPoller()Started by
NotificationHandlerwhennotificationMode === 'direct'andPlatform.isDesktopOrWeb(not used on mobile, FCM handles delivery)Uses recursive
setTimeoutso interval changes take effect on next tickConfigurable polling interval per-profile (default 30s)
Optional
Notes REGEXP:detected:filter for object-detection-only eventsMaintains a seen-event set (capped at 500) to avoid duplicate notifications
components/NotificationHandler.tsx: Headless component that
orchestrates the notification lifecycle:
Auto-connects WebSocket (ES mode) or starts poller (Direct mode on desktop)
Listens for
window.onlineand@capacitor/networkto trigger reconnect on network restoreDesktop:
visibilitychangelistener checks WebSocket liveness on tab resumeMobile:
appStateChangelistener checks WebSocket liveness on app resumeDisplays toast notifications for new events
Clears native badges on app resume (iOS/Android)
Listens to
navigationServiceevents and forwards them to React Router (with state for back-button and lastRoute control)
Kiosk PIN Utility (lib/kioskPin.ts)
Handles hashing, storage, and verification of the kiosk mode PIN. The PIN is never stored in plain text, it is hashed with SHA-256 and a random 128-bit salt before being written to secure storage.
Storage: Uses secureStorage.ts (Keychain on iOS, Keystore on
Android, encrypted localStorage on web). Keys: kiosk_pin_hash and
kiosk_pin_salt.
Functions:
hashPin(pin, salt): Promise<string>: returns hex-encoded SHA-256 ofsalt + pin. Exported for testing; not normally called directly.storePin(pin): Promise<void>: generates a random salt, hashes the PIN, and stores both hash and salt in secure storage.verifyPin(pin): Promise<boolean>: retrieves the stored hash and salt, hashes the candidate PIN, and compares.hasPinStored(): Promise<boolean>: returnstrueif a PIN hash is present in secure storage.clearPin(): Promise<void>: removes the hash and salt from secure storage (used when the user disables kiosk mode via settings).
Usage:
import { storePin, verifyPin, hasPinStored, clearPin } from '../lib/kioskPin';
// First-time setup
if (!(await hasPinStored())) {
await storePin('1234');
}
// Unlock attempt
const ok = await verifyPin(enteredPin);
// Remove PIN
await clearPin();
Used By: hooks/useKioskLock.ts (setup flow),
components/kiosk/KioskOverlay.tsx (unlock verification),
pages/Settings.tsx (PIN set/change/clear in Advanced section)
log-file (lib/log-file/)
Mirrors entries from useLogStore to a persistent file on disk.
Capabilities and file locations by platform:
Capacitor (iOS / Android): NDJSON file at
Directory.Data/zmninja-ng.log(sandboxed app data). Resolved at runtime to afile://URI on iOS andcontent://URI on Android. Share via the system share sheet, recipient receives the file as an attachment.Electron (desktop): NDJSON file in the Electron user data directory. Concrete paths:
macOS:
~/Library/Logs/com.zoneminder.zmNinjaNG/zmninja-ng.logWindows:
%LOCALAPPDATA%\com.zoneminder.zmNinjaNG\logs\zmninja-ng.logLinux:
~/.local/share/com.zoneminder.zmNinjaNG/logs/zmninja-ng.log
The “Open” button in the Logs page asks the Electron main process to reveal the file in Finder, Explorer, or the system file manager.
Web: no-op fallback; Share reverts to today’s blob download.
getDisplayPathreturnsnulland the status line is hidden.
Format: NDJSON, one LogEntry per line. Logger.formatMessage constructs the entry once and passes it to both useLogStore.addLog and LogFileStore.append.
Cap: 10,000 entries. On overflow, the file is rewritten with the last 5,000 entries.
Hydration: On app start, hydrateLogStoreFromFile() reads the file and replaces useLogStore.logs so prior-session entries are visible in the Logs page.
Used by: lib/logger.ts, pages/Logs.tsx.
Token freshness gate
useFreshAccessToken (hooks/useFreshAccessToken.ts) returns { token, isFresh }
where isFresh is true only when the access token has more than
ZM_INTEGRATION.accessTokenLeewayMs (30 minutes) of validity remaining. While not
fresh, the hook returns token: null and asks the auth store to refresh in the
background. Any callsite that builds a token-bearing URL the browser or native
runtime loads directly (ZMS streams, event images and videos, push-notification
image backfills) gates URL construction on isFresh. While not fresh, the
callsite emits an empty URL and the existing VideoOff placeholder shows
through.
For non-React async paths, call useAuthStore.getState().getFreshAccessToken()
directly. The action dedupes concurrent callers, falls through from refresh to
credentials re-login on failure, and resolves with null if both fail.