setStreamScale was called once while the monitors were being started, before
changeScale had sized the feed, so it computed from an unsized frame, hit the
lower clamp and asked for scale=25. On a 1920x1080 monitor shown at 747px wide
that served a 480px image for the browser to enlarge, which looked soft.
changeScale now sets the scale from the width it just fitted, rounded down to
the nearest 5 the way scaleToFit does.
The three profiles' settings were aliased by a switch with one arm per
profile, each repeating the same 18 define() calls against a different
ZM_WEB_<H|M|L>_ prefix. Sixty three lines in which only a single letter
differed, and nothing held the arms in step: a setting added to one and not
the others is undefined for two thirds of users, which is the same blank page
the missing default case caused, just narrower.
Name the settings once and build both sides from the prefix. The two settings
that carried a defined() guard keep it, as a separate list with the fallback
each one uses, so a genuinely absent setting is still distinguishable from a
mistyped one - the rest go through constant() and fail loudly.
Looking up an unknown profile now yields the low prefix instead of skipping
every define, so the skin config no longer depends on skin.php having clamped
the cookie first; that clamp remains the place a bad value is corrected.
Verified by diffing every resulting ZM_WEB_ constant against the previous
implementation for each of the three profiles: 49 constants, identical values.
The test drops the checks that only made sense against the switch and gains
ones for the new shape. It loads the skin config in a child process, once per
profile probed, because constants cannot be redefined and loading it is itself
what can fail. It now catches a mistyped setting name, a removed fallback, a
profile the whitelist does not know, and a setting dropped from the alias list
- the last by way of the per-profile config options, which are the authority
on which settings exist and are independent of the lists under test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
web/skins/classic/includes/config.php defines all 18 ZM_WEB_* constants inside
a switch on $_COOKIE['zmBandwidth'] with cases for high, medium and low and no
default. On any other value none of them are defined, and skin.js.php - emitted
in the footer of every page - reads ZM_WEB_VIEWING_TIMEOUT, ZM_WEB_AJAX_TIMEOUT
and ZM_WEB_REFRESH_NAVBAR. On PHP 8 an undefined constant is a fatal Error, so
every page including login stops rendering until the cookie is cleared, which
cannot be done from inside the interface.
skin.php only tested the value for empty, and nothing else validated it:
- the cookie is set client side by skin.js, so any value survives
- action=bandwidth put $_REQUEST['newBandwidth'] through validStr, which is
only strip_tags, and persisted it
- ZM_BANDWIDTH_DEFAULT is a free-form string in ConfigData. The Options UI
renders it as a select, but loadConfig lets a conf.d file override the
database, so a typo there locks out everyone with no cookie yet
skin.php now validates both the cookie and ZM_BANDWIDTH_DEFAULT before falling
back to low, and the action rejects a value it does not recognise rather than
storing it.
tests/php/test_bandwidth_clamp.php checks the whitelist against the switch it
guards - the two must name the same profiles, since a value in one and not the
other reopens this - and that no arm of the switch defines a constant the
others do not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
The C++ loader reads back only the rows where Value differs from DefaultValue
and takes compiled-in defaults for the rest, so the two columns have to be
written in the same form. Both writers passed Value through a boolean
conversion and DefaultValue through none: a boolean left alone was stored as
Value '1' against DefaultValue 'yes', never compared equal, and was read back
on every start. The filter did nothing for the 77 boolean rows.
Both columns now go through ConfigData::dbValue. It lives there because the
two writers - saveConfigToDB for an existing install and zmconfgen for the
zm_create.sql of a fresh one - have to agree, and drifting apart is what
caused this. An option with no default is stored as its type's empty value,
matching the empty string initialiseConfig already gives it.
The generated zm_create.sql now has Value equal to DefaultValue for all 259
rows, so a fresh install reads no Config rows at all. The generated
zm_config_defines.h is byte identical, confirming the compiled-in defaults
are unaffected by the representation change.
tests/perl/test_config_default_value.pl checks the invariant over every
option in ConfigData; it needs no database, since ConfigData does not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
ConfigData wrote boolean defaults and requires clauses as 'yes'/'no' while
the Config table has always held '1'/'0', so every writer and reader had to
convert between the two. yes/no is not a boolean value and the web UI never
showed it - booleans render as a checkbox, which reads Value directly and
ignores the Hint - so the string form bought nothing and only existed to be
translated away again.
Converts the 75 boolean defaults, the 59 requires clauses that test them, and
the boolean entry in %types. OPT_FFMPEG is substituted into a boolean default
by cmake, so it changes with them.
Requires clauses reach the Config table byte for byte as before: they were
already converted to 1/0 on the way in, and the web UI string-compares them
against the stored Value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
Adds Catch2 coverage for the two behaviours the config redesign
introduced: ConfigItem converting each declared Type, the non-fatal
best-effort conversion when the accessor and the stored Type disagree,
and a freshly constructed Config carrying compiled-in defaults for every
member before any database read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Y6FieTwEXuLhhR4e2yiax
Config items with mismatched types (e.g., boolean accessed as string)
previously called exit(-1), crashing the daemon. Now logs a warning
and returns a best-effort converted value from the raw string instead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
(cherry picked from commit c6fda3d2373d1454411d1f7d24d8e8ee3ba72602)
Replace numeric ID-based config system with name-based lookup using
std::unordered_map. Config entries now have compiled-in default values,
and only rows where Value != DefaultValue are loaded from the database.
This eliminates the fragile dependency on sequential ID numbers that
required DB regeneration whenever a config entry was added.
The config generator (zmconfgen.pl) now produces three macros:
- ZM_CFG_DECLARE_LIST: declares Config struct members
- ZM_CFG_DEFAULTS_INIT: initializes members to compiled-in defaults
- ZM_CFG_MAP_INIT: registers name-to-member bindings for DB loading
Only daemon-relevant config entries (137 of 245) are included in the
C++ header; web-only settings (WEB_H_*, WEB_M_*, WEB_L_*, skin
defaults, etc.) are excluded.
Also fixes two pre-existing bugs exposed by removing numeric #defines:
- ZM_WATCH_MAX_DELAY was used as Seconds(139) instead of the actual
config value (the 139 was the config table row ID, not seconds)
- ZM_OPT_USE_AUTH evaluated as if(8) (always true) instead of
checking the actual auth setting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit 86d4a8e5f0)
Put a native input type=color beside the R/G/B number inputs, the same
control monitor.php uses for WebColour and SignalCheckColour, seeded from
the zone's AlarmRGB.
The picker has no name, so it is neither submitted nor part of the change
detection snapshot; the R/G/B inputs stay the source of truth for
validateForm and submitForm. alarmColourFromInputs keeps the swatch and
its disabled state in step with those inputs, and is called from
applyZoneType and resetChanges; alarmColourToInputs writes the picked
colour back into them and re-runs the dirty check.
Move Save, Reset and Cancel out of the column below the video and into a
float-right div in the header, as btn-normal icon buttons with tooltips,
matching the snapshot and report views. Back and Refresh stay on the left.
saveChanges now takes the form from document.zoneForm rather than
element.form, because the Save button no longer sits inside the form.
changeScale uses the stream controls as the scaleToFit bottom element now
that the button row is gone, and sets their width and left margin from the
video, so their left edge lines up with the image.
Drop the right margin from #zonePoints. It is full width inside the settings
panel, so the margin pushed it past the panel and gave the panel a
horizontal scrollbar.
Widen the settings column from 480px to 540px and move the zone point
table into it, below the settings table, so the left hand column holds
only the video, the stream controls and the Save/Reset/Cancel buttons.
Size the feed with scaleToFit(), the same helper the watch view uses,
passing the button row as the bottom element. skin.js already calls a
global changeScale() from its resize handler, so the video re-fits when
the window changes size instead of pushing the buttons out of the
#page overflow.
Give the settings table a fixed layout with wrapping row headers, and
narrow the pixel inputs, so its three columns stay within 540px. Add
pointIndex/pointX/pointY/pointAction classes to the point table cells,
both in the template and in drawZonePoints, and give the action column a
fixed 80px so the + and - buttons do not wrap onto separate lines.
Columns still stack below 900px wide.
Add a Reset button beside Save/Cancel in the zone edit view. It stays
disabled until the form differs from the state loaded from the database,
and restores every field, the zone points and the derived disabled states
when clicked.
Change detection serialises all named form fields, including the
per-point X/Y inputs, and compares against a snapshot taken at the end of
initPage, so manually undoing an edit disables the button again. Number
inputs are compared with parseFloat because updateX/limitPointValue
rewrite 100.00 as 100, which otherwise reads as a change.
Two snapshots are kept: initialValues holds the raw values used to
restore field formatting, initialState holds the normalised string used
for comparison. Point inputs are excluded from the restore because
drawZonePoints re-creates them from the restored points array.
Coordinate inputs of a point that differs from the loaded zone get a
'changed' class with a yellow background, so dragging a point shows which
coordinates moved.
The monitor attribute filters and the event filter terms were unrelated
pieces of UI that happened to sit next to each other. They are not
independent: Name, Capturing, Analysing, Recording, Status and Source do
not filter events at all, they narrow which monitors exist to choose
between - which is to say they decide what the Monitor term should offer.
Anything they exclude cannot appear in the events either, so offering it
in the term is offering a choice that returns nothing.
Filter carries the restriction rather than the widget taking it as an
argument: monitor_options() is a get/set in the same shape as terms(), so a
view states the rule on the filter and then asks that filter for a widget.
Unset, the Monitor term offers every monitor the user can view, so
events.php and any other caller is unchanged.
montagereview sets the monitors that survived its filter bar.
Verified on the install this repo matches: the events view still lists all
monitors in its Monitor term, montage review renders its event filters as
Monitor, Date Time, Date Time, Archive Status, Tags, Notes with Name,
Capturing, Analysing, Recording, Status and Source collapsed, and the
events, console and filter views all still load.
Montage review drew the monitor attribute filters inline - Name, Capturing,
Analysing, Recording, Status, Source - taking rows of the filter bar for
controls that choose which cameras appear and are rarely touched once set.
Put them behind their own control, collapsed by default.
The block keeps its place in the layout rather than being display:none:
skin.js applies hidden-shift from data-initial-state-icon, which moves it
off screen while leaving it measurable so Chosen still initialises the
selects inside it. applyChosen runs when it is opened.
The monitor selection itself is the result of those filters rather than one
of them, so it stays visible with the event filter terms. buildMonitorsFilters()
now also returns the selection on its own and the attribute filters without
it; filterBar is unchanged, so the other five callers are unaffected. The
selection is spliced into simple_widget()'s own flex row so it sits on the
same line as Date Time and the rest; emitted as a sibling it would take a
row of its own. If that markup ever changes, the replace count is checked
and it falls back to its own row with a logged warning rather than
disappearing.
A monitor selector was also being drawn twice: _monitor_filters.php renders
MonitorId[] for the session selection, and montagereview.php added the same
selection again as a Monitor IN filter term, which the filter widget drew as
a second control. Its sibling branch added a run of MonitorId = terms and
was marked "this should be redundant" when written. Both are gone; neither
is needed for the query, since the bar has already reduced $displayMonitors
and loadEventData() asks for those ids.
Verified on the install this repo matches:
event filters row Monitor, Date Time, Date Time, Archive Status, Tags, Notes
collapsed Name, Capturing, Analysing, Recording, Status, Source
one monitor selector, on the same row as the first term, starting collapsed
The non increasing dts warning printed raw timestamps only, which are in
stream time_base units and hard to interpret. Add the backwards jump
converted to seconds via av_q2d(stream->time_base).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113HRysoe2hwfEq4SdcCqFy
FilterComponent::buildFilter() checked the operator against an allow list
but never the field, so an unrecognised name went straight into the SQL:
GET /api/events/index/MonitorName =:Monitor-1.json
PDOException SQLSTATE[42S22]: Column not found: 1054
Unknown column 'MonitorName' in 'where clause' -> HTTP 500
A caller filtering on a monitor-scoped attribute (MonitorName, Monitor,
Rack, Experiment, Server, Storage) or simply mistyping a column got an
opaque 500 with a stack trace and nothing naming the offending field.
Take an optional list of permitted fields and reject anything else with
BadRequestException. EventsController::index() passes the Events columns
from the model schema plus the two names it resolves itself: DateTime, the
pseudo-attribute it turns into an overlap test, and GroupId, served by the
Groups_Monitors join. A field written Model.Field is checked on the column.
The two other callers of buildFilter pass no list and are unchanged.
The invalid-argument and invalid-operator paths threw a plain Exception,
which was also a 500 for what is bad input; they throw BadRequestException
now too.
Verified against a live install:
MonitorName / Rack / NotAColumn 400, "Unknown filter field: <name>"
DateTime >=/<= over a window 200, 7 rows, overlap intact
Cause, Event.Cause, GroupId 200
?limit=2&sort=...&direction=desc 200, 2 rows, pagination unaffected
Plus an assert script over the validation itself, including that a caller
passing no list keeps the previous pass-through behaviour.
Filters sent as a query string rather than named parameters go through a
different branch, on raw $_REQUEST, and are deliberately left unvalidated:
that array also carries auth and cache-busting parameters, so rejecting
unknown names there would break existing callers. ?MonitorName=x is still
a 500 and wants its own change.
a3b1ab558 translated the DateTime filter term to StartDateTime before
putting it in the events API URL, on the belief that DateTime was not a
real column and was being silently ignored. That was wrong.
EventsController::index() treats DateTime as a pseudo-attribute meaning
"the event was running then" and rewrites a window over it into an overlap
test, using an effective end date that falls back to StartDateTime + Length
and then to NOW() for an event with no EndDateTime (0d7abadb61).
StartDateTime is a plain column, so the same window becomes a containment
test and every event that was already recording when the window opened is
dropped. With continuous recording the window nearly always opens
mid-event, so montage review began with a blank stretch of timeline.
Measured against a live install, one monitor, 2026-06-23 16:05 to 17:05:
DateTime >=/<= 7 rows, earliest 16:01:28, includes the straddler
StartDateTime >=/<= 6 rows, earliest 16:11:39, straddler dropped
The evidence that led to a3b1ab558 was misread: a DateTime query returning
rows from over a year earlier looked like an ignored filter, but those are
crash-orphaned events with no EndDateTime and no Length, whose effective
end is NOW() and which therefore genuinely do overlap every window.
Keep the Monitor to MonitorId rewrite and the local-variable approach from
df6f48334; only the DateTime line goes.
logState() counted every row with Level < INFO and folded anything at or
below PANIC into the FATAL bucket. AUDIT is -5, so audit entries were
counted as fatals and pushed the state to alert/alarm.
Bound the count query at PANIC so AUDIT and NOLOG are left out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAFKf86P78WqniPEP2b45J
a3b1ab558 translated DateTime to StartDateTime by assigning to attr.value,
which edits the filter form itself, not just the query being built. The
form is submitted whenever a filter changes, and montagereview.php decides
whether the range terms are already there with
if (!$filter->has_term('DateTime', '>=')) (montagereview.php:193)
if (!$filter->has_term('DateTime', '<=')) (montagereview.php:196)
so a form arriving with StartDateTime failed both checks and PHP appended a
second pair. Selecting a monitor left the filter bar showing four date
terms.
Verified against a live install by submitting the same filter both ways:
terms as StartDateTime -> 7 terms, 4 date terms
terms as DateTime -> 5 terms, 2 date terms
Compute the API attribute into a local and leave attr.value alone. The
pre-existing Monitor to MonitorId rewrite moves too: it has the same shape
and montagereview.php has its own 'Monitor' handling.
The URL is unchanged, so the DateTime fix still holds; only the side effect
on the submitted form is gone.
Montage review created 86 stream connkeys in five minutes while only two of
them were ever polled, and each stream painted a single frame before being
replaced about 1.3s later.
The status poll for a connkey whose zms had exited returned result=Error,
recover() restarted the stream under a new connkey, and the reply already
queued for the old one arrived afterwards and restarted the new stream too.
Every restart painted its first frame, which reset consecutiveErrors and
recoveryDelay in img.onload, so the backoff never grew and the attempt limit
was never reached.
- Tag each ajax/stream.php exchange with the connkey it was issued for and
ignore a reply, success or failure, once that connkey has been replaced.
stream.php does not echo the connkey back, so the client tracks it.
- Restart only on reason=no_socket, the one class that means zms is gone,
matching streamErrorIsFatal() in MonitorStream.js. The helper is duplicated
as EventStream.errorIsFatal() because montagereview.php does not load
MonitorStream.js.
- Start the poll timer next to the src that created the connkey instead of in
img.onload, which is not a dependable per-restart signal for a
multipart/x-mixed-replace img, and let the first query wait a full interval
so a stream that is still starting is not read as a missing socket.
- Reset the recovery counters only on a successful status reply, so the
exponential backoff and the attempt limit both work.
Also stop appending a second '?' to UrlToZMS, which already carries
'?monitor=N', so the logs no longer show monitor=24?source=event.
tests/js/eventstream-connkey.test.js covers the stale-reply rules, the fatal
classification and the URL separator.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YEe1M3PWVMqYWihUeAa5N
loadEventData() fires one request per monitor but assigned them all to a
single `ajax` variable, so only the last was ever abortable. The
`if (ajax) ajax.abort()` guarding re-entry cancelled one of them; the rest
stayed in flight, landed after the new query, and drew events for a window
the user had already scrubbed past. This was the //FIXME sitting above
that line.
Track the requests in an array and abort all of them on re-entry. Also
stop reporting an abort as a failure: jQuery calls the error handler with
textStatus 'abort' for a request we cancelled ourselves, which would
otherwise log one console error per monitor on every scrub.
Checked with an assert script: twelve tracked requests all abort and the
list clears, the old single-variable pattern aborts only one of twelve,
an 'abort' status is not logged while a real error still is, and the list
re-arms for the next query.
loadEventData() builds the API URL straight from the filter form's terms
and translated only one attribute name, Monitor to MonitorId. But
montagereview.php expresses both of its time bounds as the DateTime
pseudo-attribute, which is a filter-engine concept rather than an Events
column: Filter.php resolves 'DateTime' and 'StartDateTime' to the same
E.StartDateTime, and groups them together everywhere else it special-cases
date attributes.
The API takes real column names and silently ignores a term it cannot
resolve, so the two bounds were dropped and the view got page one of every
event ever recorded instead of the requested window. Both requests return
200, which is why nothing surfaced. Measured against a live install for
the 2026-08-28 00:00:00 to 01:00:00 window:
DateTime >=/<= -> 757 rows, first event 2025-04-26 16:29:55
StartDateTime >=/<= -> 300 rows, 00:00:59 to 00:59:59
Translate DateTime the same way Monitor is already translated. Rewriting
the form element is safe for the PHP path too, since Filter.php treats the
two names identically.
Firefox reports an error location as "line:column", for example 1500:28.
ajax/log.php passed that through validInt(), which is
preg_replace('/[^\-\d]/', '', $input)
so it only removes the colon and splices the two numbers into 150028.
Logs.Line is smallint(5) unsigned, max 65535, so the insert died with
PDOException SQLSTATE[22003]: Out of range value for column 'Line'
includes/logger.php:438 <- ajax/log.php:81
and the request returned 500. The endpoint failed on the very error it
was sent to report, so a js error surfaced as "AJAX execution error:
Internal Server Error [500]" instead of a log entry. Any Firefox error
past roughly line 655 hits this, on any view.
Parse the leading line number instead and clamp it to what the column
holds. validInt() is left alone; it is used widely and its callers rely
on the strip-everything behaviour, the defect is handing it a line:column
string.
Checked with an assert script over 1500:28, 1789:8, a plain 1500, an
over-range 70000 and 99999:1, empty, non-numeric, and array input; the
first three give the line, the next two clamp to 65535, the rest stay NULL.
Two cases produced a Warning for a request that needs no CORS headers at
all, so the log filled with noise that pointed at nothing wrong.
An empty Origin header satisfies isset(), so CORSHeaders() walked the
servers list, matched nothing, and logged " is not found in servers list."
with no value to print. Treat an empty Origin the same as no Origin.
Browsers also send Origin on same-origin POST and fetch. Such a request
needs no headers, but not finding the host in the Servers table still
warned, so any install reached on a hostname the Servers table does not
list warned on ordinary use. Log that at Debug instead. Genuine
cross-origin requests still warn.
Headers are unchanged; this only affects logging and the empty-Origin
short circuit. The comparison ignores the scheme, so http:// against an
https-served HTTP_HOST counts as same-origin - that only suppresses a log
line, no header is emitted either way.
Checked with a standalone assert script over same-origin with and without
a port on both schemes, differing port, differing host, a suffix near-miss
(hamburg.local vs hamburg) and a missing HTTP_HOST; only the first three
go quiet.
Coords holds a space-separated list of "x,y" points, written with two
decimal places by pointsToCoords()/mapCoords(). Since 1.39.2 the Zones
values are percentages rather than pixels, which costs up to 13 bytes per
point ("100.00,100.00") against the 9 a pixel pair used ("1920,1080").
tinytext caps at 255 bytes, so a zone of roughly 19 points or more
overflows and is silently truncated, leaving a corrupt polygon. In pixel
form the same zone fit, so this only starts biting at the conversion.
Widen the column in 1.39.2 before its conversion runs, and again in a new
1.39.23 for installations that ran that conversion before the widening
existed - zmupdate.pl only applies files at or above the database version,
so those never revisit 1.39.2. ALTER ... MODIFY to the same type is a
no-op, so both are safe to re-run.
Maps stores coordinates in the same format and is widened to match. It
was added to zm_create.sql.in in 1.32 without a matching update script,
so installations older than that have never had the table; guard that
statement on the table existing rather than failing the whole migration.
Update zm_create.sql.in for both tables so a fresh install matches an
upgraded one, and bump version.txt so the new update script is reached.
The Coords pixel-to-percent conversion is already idempotent: the cursor
loop skips any zone whose Coords contain a '.', and CAST(... AS
DECIMAL(10,2)) always renders two decimals, so a converted zone can never
be mistaken for a pixel one.
The Area rescale that followed it had no such guard. It ran as a separate
statement over every zone with Area > 0, with nothing to distinguish a
zone converted by this run from one converted by an earlier one, so each
run divided Area by (Width * Height) / 10000 again. Re-running does not
repair it.
This is not hypothetical. zmupdate.pl applies every update file whose
version is >= the database version, so the file matching the current
version is re-run on each upgrade. A live database had a full-frame zone
on a 1920x1080 monitor holding Area 48, which is
ROUND(10000 * 10000.0 / 2073600) - the rescale applied to an already
rescaled value. Two sibling zones had reached 0. Their Coords were
intact throughout, confirming the conversion held while Area decayed.
Fold the rescale into the UPDATE inside the loop so it can only touch a
zone whose Coords were just converted. Zones skipped as already-percent
keep their Area.
Area is recoverable without a backup: web/includes/actions/zone.php
recomputes it with getPolyArea() from Coords whenever a zone is saved.