Watchtower Settings Developer Guide - edmozley/freeitsm GitHub Wiki

Watchtower Settings โ€” Developer Guide

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.


1. The design decision, and the one that was wrong first

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:

  1. It is not a fact about the status. is_closed, is_default and pauses_sla are 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.
  2. 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.
  3. 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.
  4. 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_hours had no interface at all, and asset_warranty_surface (values dashboard / 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.


2. The storage model

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)
);

๐Ÿ”ด is_customised is the load-bearing column

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.

Members are rows, not a column

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.

No foreign key, on purpose

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.


3. ๐Ÿ“ The files involved

๐Ÿ—„๏ธ 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.


4. Adding a new selectable count

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.

Adding a card

Add the key to wtCardKeys(), the element id to WT_CARD_ELEMENTS in watchtower/index.php, and card_<key> / card_<key>_desc labels.

โš ๏ธ A card that manages its own visibility must honour the setting itself. The Workflows card hides when the module is unused, and 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; }

5. ๐Ÿ”ด The rule that shaped the whole thing

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.


6. ๐Ÿ”ด Do not borrow a domain fact to make a view decision

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.


7. One definition, many consumers

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.


8. Verifying a change here

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.


9. Scoping the dashboard: Mine / My team / Everyone (#58)

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.

๐Ÿ”ด The scope goes in the JOIN, not the WHERE

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.

๐Ÿ”ด A morning check is routed through its GROUP

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 is AND (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.

๐Ÿ”ด Some numbers must never be scoped

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.

Adding scope to another card

  1. Get the clause once at the top of the card:
    [$xScopeSql, $xScopeArgs] = wtScopeClause($conn, $analystId, $scope, 'c.assigned_to_id');
  2. Append {$xScopeSql} to each query and convert $conn->query() to prepare() + execute($xScopeArgs). For a LEFT JOIN count, append it to the ON.
  3. Where a query already has placeholders, order the arguments to match:
    $stmt->execute(array_merge([$today], $xScopeArgs));
  4. 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.

The front end

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() and renderWorkflows() both set display themselves, 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.


Related pages

โš ๏ธ **GitHub.com Fallback** โš ๏ธ