Commit Graph
29148 Commits
Author SHA1 Message Date
singhharsh1708 58ea7e3bb5 refactor: remove the FrameSkip control and its unread member refs #3570 2026-09-02 20:10:36 +05:30
Isaac Connor 9f6f8338e6 Merge pull request #5078 from AJ0070/fix/4215-report-monitor-delete-error
fix: report a failed monitor delete instead of silently ignoring it
2026-08-31 23:10:12 -04:00
Isaac Connor c7399cc9c9 Merge pull request #5081 from AJ0070/fix/3301-events-ajax-error
fix: stop the events table loading forever when the query fails
2026-08-31 22:57:42 -04:00
Isaac Connor 48b2fc3671 Merge pull request #5084 from ZoneMinder/dependabot/github_actions/github/codeql-action-4.37.9
chore(deps): bump github/codeql-action from 4.37.8 to 4.37.9
2026-08-31 21:36:58 -04:00
dependabot[bot] 0963471af5 chore(deps): bump github/codeql-action from 4.37.8 to 4.37.9
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.8 to 4.37.9.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.8...v4.37.9)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-31 17:43:45 +00:00
Jash Ambaliya af9c3c8d10 Merge branch 'master' into fix/3301-events-ajax-error 2026-08-31 18:03:02 +05:30
Jash 461adbbd20 fix: show the events table error and clear loading on both paths refs #3301 2026-08-31 17:40:09 +05:30
Isaac Connor bd74472fa3 Merge pull request #5079 from AJ0070/docs/4486-zone-min-alarm-area
docs: minimum alarmed area cannot be 0
2026-08-30 22:35:57 -04:00
Isaac Connor 859af3a8f4 Merge pull request #5070 from AJ0070/fix/4423-zmcontrol-lock
fix: lock to stop a second zmcontrol server per monitor
2026-08-30 15:39:17 -04:00
Isaac Connor 90c95d2ccf Merge pull request #5082 from AJ0070/docs/4205-render-group
docs: note the render group for vaapi and qsv decoding
2026-08-30 11:18:10 -04:00
Jash 386a12a174 fix: record the lock holder pid and separate held from unopenable refs #4423 2026-08-30 03:47:14 +05:30
Jash 5f8e99f05f docs: note the render group for vaapi and qsv decoding fixes #4205 2026-08-30 01:53:35 +05:30
Jash eff68f4103 fix: stop the events table loading forever when the query fails refs #3301 2026-08-30 01:52:22 +05:30
Jash 3082cdd9f9 docs: minimum alarmed area cannot be 0, the zone form rejects it fixes #4486 2026-08-30 01:39:13 +05:30
Jash a0ef89d1f3 fix: report a failed monitor delete instead of silently ignoring it fixes #4215 2026-08-30 01:37:45 +05:30
Isaac Connor aa30a5a069 Merge branch 'master' of github.com:ZoneMinder/zoneminder 2026-08-29 14:03:48 -04:00
Isaac Connor a91c93632a Merge branch 'zone-edit-reset-button' 2026-08-29 14:02:59 -04:00
Isaac Connor 9c808fe14f fix: request a stream scale that matches the sized zone feed
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.
2026-08-29 14:02:53 -04:00
Isaac ConnorandClaude Opus 5 8044af78e2 refactor: derive the ZM_WEB_ bandwidth aliases from the profile prefix
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
2026-08-29 14:01:25 -04:00
Isaac ConnorandClaude Opus 5 465c3e2dfc fix: reject unrecognised zmBandwidth values instead of undefining every ZM_WEB_ constant
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
2026-08-29 14:01:25 -04:00
Isaac ConnorandClaude Opus 5 94f41aab27 fix: write Config.DefaultValue in the same form as Config.Value
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
2026-08-29 14:01:25 -04:00
Isaac ConnorandClaude Opus 5 a041d67696 refactor: store boolean config values as 1/0 instead of yes/no
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
2026-08-29 14:01:25 -04:00
Isaac ConnorandClaude Opus 5 7002a6e516 test: cover name-based config lookup and best-effort type conversion
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
2026-08-29 14:01:25 -04:00
Isaac Connor 578ba69551 fix: make config type mismatch non-fatal with best-effort conversion
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)
2026-08-29 14:01:25 -04:00
Isaac Connor 5f4304c772 refactor: redesign C++ config to use name-based lookup with compiled-in defaults
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)
2026-08-29 14:01:25 -04:00
Isaac Connor 5e80cfedcc feat: add a colour picker for the zone alarm colour
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.
2026-08-29 13:33:16 -04:00
Isaac Connor a80069ee00 feat: move the zone edit buttons into the header toolbar
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.
2026-08-29 13:28:39 -04:00
Isaac Connor 8357a22aed feat: rework the zone edit layout so the buttons stay on screen
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.
2026-08-29 12:40:02 -04:00
Isaac Connor 268dab15dc Merge pull request #5073 from AJ0070/fix/4850-show-progress
fix: honour ZM_WEB_SHOW_PROGRESS in the event view
2026-08-29 11:26:33 -04:00
Isaac Connor 08f241c0c0 feat: add reset button and changed-point highlighting to zone edit view
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.
2026-08-29 10:58:00 -04:00
Isaac Connor d73c7b18da feat: let the monitor filters decide the Monitor term's options
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.
2026-08-28 23:07:49 -04:00
Isaac Connor a229f04d0e Make actions can ono longer be performed with POST a debug 2026-08-28 22:31:29 -04:00
Isaac Connor 61dbe22728 Failure to open a config file shouldn't be fatal. Could be, but not necessarily 2026-08-28 22:30:51 -04:00
Isaac Connor 0e88922676 feat: collapse the monitor attribute filters, keep the selection on show
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
2026-08-28 21:09:36 -04:00
Isaac ConnorandClaude Opus 5 8a4ac9146b fix: log dts regression size in seconds in non increasing dts warning
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
2026-08-28 20:43:16 -04:00
Isaac Connor 4d82e1d385 fix(api): answer 400 naming the field instead of 500 on an unknown filter
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.
2026-08-28 19:54:07 -04:00
Isaac Connor 8358e42832 fix: stop rewriting DateTime to StartDateTime, it disabled the overlap test
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.
2026-08-28 19:34:51 -04:00
Isaac ConnorandClaude Opus 5 f2424bcf59 fix: stop AUDIT log entries turning the navbar Log link red
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
2026-08-28 17:29:00 -04:00
Isaac Connor df6f48334f fix: translate filter attrs for the API without rewriting the form
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.
2026-08-28 12:52:04 -04:00
Isaac ConnorandClaude Opus 5 cbd68bb323 fix: bind the EventStream status poll to the connkey it belongs to
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
2026-08-27 22:39:45 -04:00
Isaac Connor b4eaa98478 fix: abort every in-flight event query, not just the last one
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.
2026-08-27 21:12:35 -04:00
Isaac Connor a3b1ab558b fix: translate the DateTime filter term to StartDateTime for the events API
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.
2026-08-27 20:37:34 -04:00
Isaac Connor 56030724ed fix: stop the js log endpoint 500ing on a Firefox line:column location
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.
2026-08-27 19:54:42 -04:00
Isaac Connor d8f1a7d2d4 fix: stop CORSHeaders warning about empty and same-origin requests
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.
2026-08-27 18:34:54 -04:00
Isaac Connor 81cfb438ca fix: widen Coords from tinytext to text on Zones and Maps
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.
2026-08-27 16:19:24 -04:00
Isaac Connor d70f421483 fix: stop 1.39.2 rescaling Zones.Area on every run
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.
2026-08-27 16:19:11 -04:00
Jash a2ac2eaa0c fix: honour ZM_WEB_SHOW_PROGRESS in the event view fixes #4850 2026-08-26 20:07:00 +05:30
Isaac Connor f8677463ea Merge pull request #5071 from AJ0070/fix/3681-double-scale
perf: scale during colour conversion in zms playback
2026-08-26 09:22:29 -04:00
Jash 5a369adb8e docs: the conversion is what resizes, not the decode refs #3681 2026-08-26 18:48:15 +05:30
Jash 5e0d6afaf4 fix: send pre-scaled frames as built when scale or zoom changes refs #3681 2026-08-26 18:22:39 +05:30