Skip to content

Watchtower Settings Developer Guide

Ed Mozley edited this page Aug 19, 2026 · 1 revision

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.


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally