Watchtower Settings Developer Guide - edmozley/freeitsm GitHub Wiki
How the dashboard's view preferences are stored, why they are stored there, and what to do when you add a card or a count.
The user-facing description is Watchtower Settings.
Watchtower is the only module that owns no data. It reads every other module and reports what needs attention. So the question "which statuses should appear on the dashboard" has two plausible homes, and the first answer given was the wrong one.
The rejected design: a flag on the status row. Add show_on_watchtower to ticket_statuses, task_statuses and the rest, and tick it where the statuses are already managed.
It fails on four counts:
-
It is not a fact about the status.
is_closed,is_defaultandpauses_slaare properties the SLA engine and half the application depend on. A dashboard preference is not the same kind of thing and must not sit beside them. - It only serves breakdowns. A boolean says "give this status its own row". It cannot express "these five priorities add up to one number", which is what the high-priority line is.
- It cannot grow. Today that line is one red item. If it ever needs Urgent red and High amber, a boolean column has nowhere to put the severity.
-
You would tune Watchtower by touring three other modules. Which was already the situation for the two Watchtower settings that existed โ
watchtower_paused_too_long_hourshad no interface at all, andasset_warranty_surface(valuesdashboard/both) lives in Assets settings.
What survives from it is the narrower rule, and it is the one to apply elsewhere: intrinsic facts stay on the row; view preferences live with the view.
Two tables. analyst_id 0 means the installation's setting โ real analyst ids are reserved so per-person overrides can be added later without a migration. (Zero rather than NULL because NULL does not de-duplicate in a unique key.)
CREATE TABLE watchtower_items (
id INT NOT NULL AUTO_INCREMENT,
analyst_id INT NOT NULL DEFAULT 0,
item_key VARCHAR(60) NOT NULL, -- 'card.tickets', 'tickets.high_priority', โฆ
is_visible TINYINT(1) NOT NULL DEFAULT 1,
is_customised TINYINT(1) NOT NULL DEFAULT 0,
UNIQUE KEY (analyst_id, item_key)
);
CREATE TABLE watchtower_item_members (
id INT NOT NULL AUTO_INCREMENT,
analyst_id INT NOT NULL DEFAULT 0,
item_key VARCHAR(60) NOT NULL,
entity_type VARCHAR(30) NOT NULL, -- 'ticket_status', 'impact_level', โฆ
entity_id INT NOT NULL,
severity VARCHAR(10) NULL, -- reserved
UNIQUE KEY (analyst_id, item_key, entity_type, entity_id)
);It separates "not configured" from "configured to show nothing".
Without it, an empty member list is indistinguishable from an untouched one, and the code would have to treat empty as "all" โ so an administrator who deliberately unticked everything would get everything. With it:
- no row, or
is_customised = 0โ use the built-in default (count everything, read the flags); -
is_customised = 1โ use exactly the selected members, including none.
This is what lets the screen only ever trim a dashboard that is already correct. See ยง5.
Because an item can hold a set rendered as one number, and because a member can carry its own severity later without a schema change.
entity_type is polymorphic โ the target table varies per row, so no FK could cover it. Reads join the real lookup table instead, which drops a member whose status has since been deleted rather than counting a ghost:
$stmt = $conn->prepare(
"SELECT m.entity_id
FROM watchtower_item_members m
JOIN `{$spec['table']}` e ON e.`{$spec['id']}` = m.entity_id
WHERE m.analyst_id = ? AND m.item_key = ? AND m.entity_type = ?"
);Writes validate the same way, so an id that is not a real lookup of that kind is never stored.
๐๏ธ schema ยท ๐ read ยท โ๏ธ write ยท ๐ API ยท ๐ฅ๏ธ UI ยท ๐ permissions
| ๐จ | File | What you do there | Skippable? |
|---|---|---|---|
| ๐๏ธ | database/freeitsm.sql |
The two tables | No |
| ๐๏ธ | includes/db_verify_schema.php |
The same columns, so Verify creates them on upgrade | No |
| ๐ | includes/watchtower_settings.php |
Start here. wtCardKeys(), wtSelectableItems(), wtVisibleCards(), wtItemMembers()
|
No |
| ๐ | includes/watchtower_queries.php |
Where each setting is applied to a query | No |
| ๐ | api/watchtower/get_settings.php |
Returns every choosable member alongside the current selection | No |
| ๐ | api/watchtower/save_settings.php |
Validates and writes; checks the two capabilities separately | No |
| ๐ฅ๏ธ | watchtower/settings/index.php |
The screen. COUNT_ITEMS declares what appears on the Counts tab |
No |
| ๐ | watchtower/settings/manifest.php |
The tabs, and therefore the capabilities | No |
| ๐ | includes/capabilities.php |
WATCHTOWER_MANAGE / _CARDS / _COUNTS
|
No |
| ๐ฅ๏ธ | watchtower/includes/header.php |
The Settings link, shown only to analysts holding a tab | Yes |
| ๐ฅ๏ธ | watchtower/index.php |
applyCardVisibility() and the per-card renderers |
Only for a card |
| ๐ | lang/en/watchtower.php |
Labels under settings.*
|
No |
What you do not touch: the lookup tables themselves. No status, priority or impact level gained a column for any of this.
Four steps.
1. Declare where the options come from โ includes/watchtower_settings.php:
'tasks.by_status' => [
'entity_type' => 'task_status',
'table' => 'task_statuses', 'id' => 'id', 'name' => 'name',
'where' => 'is_closed = 0 AND is_active = 1', 'order' => 'display_order, name',
],The API reads options straight from this, so the screen never needs to know what anybody's statuses are called.
2. Apply it in the query โ includes/watchtower_queries.php:
$picked = wtItemMembers($conn, 'tasks.by_status');
$pickSql = $picked === null ? '' : ' AND ts.id IN ' . wtIdListSql($picked);null means not customised โ fall through to the built-in behaviour. Never treat null and [] the same.
3. Add it to the screen โ COUNT_ITEMS in watchtower/settings/index.php, with the card key that gives it its module colour and badge.
4. Add the labels โ item_<name> and item_<name>_why in lang/en/watchtower.php.
Add the key to wtCardKeys(), the element id to WT_CARD_ELEMENTS in watchtower/index.php, and card_<key> / card_<key>_desc labels.
applyCardVisibility() runs before the renderers โ so without this it would overrule the setting:
const hiddenBySetting = (d.cards || {}).workflows === false;
if (!wf || !wf.available || hiddenBySetting) { card.style.display = 'none'; return; }Correctness must not depend on configuration.
When the hardcoded-name faults were found in Watchtower, the tempting fix was to ship this screen and let administrators tick the right statuses. That would have meant:
- every existing installation stays wrong until somebody finds the screen;
- a fresh installation is wrong until somebody configures it;
- and a dashboard showing nothing reads as "nothing needs attention" โ the most dangerous thing it can say untruthfully.
So the queries were corrected first, with no configuration at all, and this screen shipped second as a trim. Every default here reproduces the corrected behaviour exactly.
When you add a setting, its default must be the answer you would have hardcoded. If you cannot express that default without configuration, the query is not finished yet.
The clearest mistake in this work was made while fixing a different one.
Service Status' red-versus-amber decision originally compared the impact level's name against 'Major Outage'. Removing that, it was pointed at the level's counts_as_downtime flag โ which reads well, until somebody wants a degraded service drawn amber. That flag decides their uptime percentages. The only way to change the colour would have been to falsify uptime reporting.
They are now two settings: service.serious decides the colour, counts_as_downtime decides uptime, and the first defaults to the second so nothing changed for anyone.
Test for it: if changing a display preference would require editing a value another module reports on, the preference needs its own storage.
Three things read the dashboard payload: the Watchtower page, the browser extension's badge, and its popup. When each worked out "how many morning checks need attention" for itself, all three did it by counting a status called 'Fail' โ a name FreeITSM has never shipped.
morning_checks.attention_count is now computed once, server-side, from the administrator's selection or the fallback rule, and all three read it.
If a rule has more than one consumer, compute it where the data is. Three copies of a rule is how all three came to be counting a status that did not exist.
See Browser Extension โ Developer Guide.
Two checks, both cheap.
The rename diff โ the real test that no name is being matched. Capture the endpoint, rename every configurable lookup, capture again, diff every numeric and boolean field, and confirm the labels did change. Full method in the lookups guide.
Drive the screen, do not read it. Both tab panes exist in the DOM at all times and only one carries .active, so querying their contents "proves" both tabs work while the switching may be entirely absent โ which is exactly how a dead Counts tab shipped and had to be fixed in 4a699389. Click the tab, assert which pane is visible, and attach an error listener to catch what the click throws.
getWatchtowerData($conn, $analystId, $scope). The third argument is new; two arguments still behave exactly as before, which is why the browser-extension endpoint needed no change at all.
Everything lives in includes/watchtower_settings.php:
| Function | Answers |
|---|---|
wtScopeFor() |
what this analyst last chose (user_preferences.watchtower_scope) |
wtScopeClause() |
a AND <column> = ? / IN (โฆ) fragment plus its parameters |
wtTeamAnalystIds() |
everyone on any team the analyst is in, including themselves |
wtMorningCheckScope() |
the bespoke clause for checks, which are routed differently |
wtImpersonalCards() |
the six cards that cannot narrow |
wtImpersonalOnMine() |
show or hide (user_preferences.watchtower_impersonal) |
No schema change: both preferences are ordinary user_preferences rows.
Every by-status list is a LEFT JOIN so that a status with no work still appears with a zero:
-- RIGHT
FROM ticket_statuses ts
LEFT JOIN tickets t ON t.status_id = ts.id AND t.assigned_analyst_id = ?
WHERE ts.is_closed = 0
-- WRONG: the LEFT JOIN silently becomes an INNER one
FROM ticket_statuses ts
LEFT JOIN tickets t ON t.status_id = ts.id
WHERE ts.is_closed = 0 AND t.assigned_analyst_id = ?In the WHERE, every row where the join found nothing is discarded โ so a status you happen to own nothing in vanishes from the card rather than showing 0, and the list is shorter on Mine for a reason nobody can see. Assert the number of statuses listed is identical across all three scopes; on this install that is 5 tickets and 3 tasks.
morningChecks_Checks.AssignedAnalystID exists and on a real install is empty on every row. Routing is done by putting a check in a group and pointing the group (morningChecks_Groups.AssignedTeamID / .AssignedAnalystID) at a team or a person.
Scope on the check's own column and every analyst is told they have 0 of 0 checks โ a confident zero on the one card whose job is to say otherwise. Watchtower has shipped that shape of bug before, counting statuses by names nothing matched. wtMorningCheckScope() therefore matches the check or its group, and the queries LEFT JOIN morningChecks_Groups g so the clause has a g to talk about.
โ ๏ธ Bracket the OR group. The clause isAND (a OR b OR c)appended to a WHERE that already contains an AND. Without the outer brackets the last branch binds alone and the filter quietly does nothing โ the same trap as the tenancy clauses.
tickets.unassigned stays team-wide on every setting. An unassigned ticket is by definition not yours, so "mine" is 0 for ever โ and 0 there reads as the queue is clear, the opposite of what an unclaimed pile means.
The same reasoning drives wtImpersonalCards(). Note that assets is in that list although users_assets exists: an asset is assigned to a user, and an analyst is not a user โ different tables, and an analyst often has no user row, so "my assets" would be empty for nearly everybody.
- Get the clause once at the top of the card:
[$xScopeSql, $xScopeArgs] = wtScopeClause($conn, $analystId, $scope, 'c.assigned_to_id');
- Append
{$xScopeSql}to each query and convert$conn->query()toprepare()+execute($xScopeArgs). For aLEFT JOINcount, append it to the ON. - Where a query already has placeholders, order the arguments to match:
$stmt->execute(array_merge([$today], $xScopeArgs));
- Remove the card from
wtImpersonalCards().
โ ๏ธ The column name is interpolated into the SQL. Every caller passes a literal; it must never come from a request.
markImpersonalCards() in watchtower/index.php marks or hides the six, driven by impersonal_cards from the server โ not a second list in JavaScript, which would drift the first time a card gained an owner column.
๐ด It runs LAST, and only ever HIDES.
applyCardVisibility()andrenderWorkflows()both setdisplaythemselves, so calling it earlier has its decision silently overwritten a few lines later. And un-hiding would resurrect a card the settings screen switched off, or the Workflows card on an install with no workflows โ neither of which is its business.
The scope is rendered server-side into the toggle so the lit button matches the data on first paint. Painting "Everyone" and swapping to "Mine" a moment later reads as a bug; the tickets calendar has the same note for the same reason.
tests/watchtower-scope-live.html drives the real dashboard and restores the preference afterwards.
- Watchtower settings โ the user-facing description
- Watchtower โ the dashboard
- Never identify a lookup row by its name โ the faults this screen was built on top of
- Browser extension โ Developer Guide โ the other consumer of the same payload
-
Roles โ Developer Guide โ manifests, capabilities and
capSelfCheck() -
Database verification โ the
$schemaarray the two tables must appear in