Commit Graph

14 Commits

Author SHA1 Message Date
cproudlock
13c46af5ce Rebuild the dashboard around what needs a person
The board was one card per source in a fixed grid, and both halves of that hurt.

Every source got the same small box whatever it had to say. Warranties showed 5
of 50 behind "and 45 more"; the crash card truncated at 4; a one-row card sat
beside a four-row card and left a hole. Nothing had room, which is the complaint
that started this: not enough space to relay the data.

And a box per source flattened priority. An enforcement failure and a warranty
notice looked equally urgent because each owned a card, while the largest type
on the page belonged to counts that are true every day and need nobody.

WHAT THE PAGE IS NOW

A severity rollup, one worst-first queue of everything that needs a person, and
a row of context beneath it.

The queue merges every critical and warning row and sorts them, so reading order
is work order. It is full width, which is where the room came from. Rows are
capped per source, because fifty expiring warranties must not bury two crashed
PCs, and what did not fit is named per source with a link to it - a bare "and 45
more" that leads nowhere is worse than saying nothing.

The rollup counts ROWS, not cards, and filters the board in place rather than
navigating away, so the counts and the rows cannot disagree.

Severity, position and permission were already declared per card and already
sorted on. The old layout threw that away; this only stops it.

WHAT WENT, AND WHY IT SHOULD NOT COME BACK

Four asset tiles plus an "All assets" tile: machines + PCs + printers + network
IS all assets, so the page showed a total beside its own addends at the same
weight. It is now a line under a rule, where a total belongs.

The printer strip. It carried the printer count a third time - the tile row and
"Total Printers" were already the same number - and "Online" equalled total, so
three tiles held one fact. A genuinely low cartridge already reaches the queue
through the printers-supplies card, which is the moment anyone wants it.

Three cards whose whole job was to display 0.

Recent Devices: the largest block on the page, listing five machines that were
all "In Use" with business unit "TBD". Recency is not a reason to look at
something.

CONTRACT 0.26.0: 'shortname'

Cards may declare a short label for their rows in the merged queue. Their
'title' is written as a heading - "Endpoints with crashes or shutdowns" - and as
a row badge it ran wider than the hostname it qualified, putting the badge
rather than the asset at the start of every line. CSS-truncating the title was
tried first and was worse: four consecutive rows reading "ENDPOINTS WITH CR..."
say nothing at all. Optional and defaults to the title, so a plugin that does
not declare one is unaffected. Additive field, minor bump.

ALSO: "silent 1 days"

A meta suffix is written for the plural case and the card cannot know the value.
One row saying it was a shrug; the merged queue puts every quiet endpoint on one
screen, so it said it five times in a column and read as broken. Now singularised
at exactly 1, on the first word, keeping what follows: "1 event (48h)", "silent 1
day". The test asserting ' ms' survives is what caught the first version turning
it into ' m'.

DashboardRow.vue is extracted because the queue and the context panels render
the same row, and the map tooltip, link fallbacks and chips are too easy to get
subtly different in a second copy.

Verified on screen against live data. Full suite 2178 passed, frontend 301.
2026-08-29 21:05:48 -04:00
cproudlock
d4105c2289 Say what a service state actually is, and make the badges readable
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 14s
CI / migrations-mysql (push) Successful in 43s
CI / backend (push) Successful in 4m18s
Three colour faults on the PC page, each hiding the next.

ACTIVITY BADGES WERE ALL GREY. The list renderer reads a badge colour from a
ROW FIELD, and the activity panel declared none, so every severity rendered
identically. The feed's whole job is to make a critical event visible and it
was painting it the same as a logon. The route now emits severitycolor - info
grey, warning yellow, critical red - and the panel declares it.

PAUSED AND THE TRANSITIONAL STATES WERE RED. statecolor was green-or-red-or-
grey, so a paused service looked like a stopped one. They are different: a
paused service was paused deliberately, and one stuck in Start Pending is
failing to start on a retry cycle - which is exactly what raises event 7011,
the thing behind the critical counts on the Fleet page. Both are amber now.

NAMED COLOURS ALWAYS GOT WHITE TEXT. readableText only parsed hex and returned
white for anything else, but panels emit NAMES. Green, red and grey are dark
enough that white worked by luck; amber is not, so the fix above shipped as
white-on-yellow and could not be read. Names resolve to hex before the contrast
test, so this holds for any named colour anywhere in the app rather than these
two panels.

colorStyle had no test file at all. It has one now.

Every one of these was found by looking at the rendered page. None of them
would fail a test that checked markup.

Full suite 2131 passed, vitest 273 passed.
2026-08-29 17:00:18 -04:00
cproudlock
99c40ae7c3 Say WHICH critical event, and stop the feed hiding it
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m15s
Reported from production: health showed '418 critical' while the ten most
recent events were all info. Both were true, which is the problem.

The health rollup counts critical events in a 48h window; the activity feed
shows the fifteen most recent events of ANY severity. A box that logged 418
service timeouts overnight and ordinary logons since produces exactly that
pair - a page contradicting itself with no way to tell why.

Two fixes.

The rollup now names the DOMINANT event instead of totalling: 'Service timeout
x418' rather than '418 critical'. Counting by event id costs nothing (one more
GROUP BY column) and turns a number into a lead. This is the same reasoning
that made health a phrase rather than a score; it stopped one step short.

The activity feed now fetches the newest criticals separately and merges them,
and keeps up to five of them when trimming to the cap. They hold their place in
time but survive the cut, so what health is reporting always appears in the
feed that exists to explain it.

Verified against the reported shape: 20 service timeouts two hours old under 12
newer logons. Before, the feed was fifteen logons; now it carries five critical
rows and health reads 'Service timeout x20'.

Worth noting the underlying signal is real - several hundred criticals on one
endpoint in 48h is a box crash-looping or a service failing on a retry cycle.
The display was hiding it, not inventing it.

Full suite 2124 passed, naming and parity gates green.
2026-08-28 23:41:59 -04:00
cproudlock
b1b9cdda72 Add bulk actions to the Fleet list, with a confirmation that names machines
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m8s
Select endpoints, then Collect now or Restart across the selection. Restart is
gated on endpoints.restart, both are re-checked server-side.

The confirmation names the MACHINES, not just the hostnames:

    Restart 2 computer(s)?
    GJX9B2Z3ESF, HOST-7
    This STOPS the machines these PCs serve:
      DT Office

A dialog listing eight hostnames tells an operator nothing about what they are
about to halt, and that is the whole consequence of the action on a shop floor.
Where no machine is recorded it says so, because 'none listed' and 'we did not
check' are different things at 6am. Only possible now the Machine column exists.

A 409 is reported as a SKIP, not a failure. The queue permits one command in
flight per endpoint, so a bulk action across eight PCs where two are already
busy says '6 queued' and '2 skipped - a command is already in flight'. Folding
those into an error count would make a working button look broken.

Selections clear on every reload: the rows they referred to may not be on the
new page, and acting on an invisible selection is how bulk tools surprise
people.

No per-row buttons. They were built first, and a tenth column on a nowrap table
pushed itself off the edge - selecting one row costs a single click and leaves
one command path instead of two.

Full suite 2124 passed, vitest 260 passed, naming gate green.
2026-08-28 23:16:24 -04:00
cproudlock
057cee1f02 Match the Fleet tile radius to the global card
The tiles were 6px while .card is 0.25rem, which left Fleet marginally rounder
than every other page. The global card treatment is a separate decision and has
been deferred; until it is made, this page should look like the rest of the app
rather than half-way to a mockup.
2026-08-28 23:03:29 -04:00
cproudlock
0baed4cc80 Close the gaps against the design: critical tile, filters, policy card, action placement
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m20s
Four things the mockup showed and the first cut did not have.

FLEET
  - A 'with critical events' tile, from the stability route that existed and
    nothing called. Clicking it narrows to rows whose health is not ok - client
    side, because the health rollup is assembled per row after pagination, so
    it narrows the PAGE rather than the fleet. Worth knowing before the count
    is read as fleet-wide.
  - PC type and Location filters. The list route already accepted both; there
    was simply no control. Both use listAll, not list: perpage is clamped to
    100 server-side, so a long location list would lose its tail in a dropdown
    while still looking complete.
  - The machine cell shows number and name together where they differ, so
    '3105 Okuma' rather than a bare number.

PC DETAIL
  - A Policy card, contributed by the computers plugin. It matters because the
    effective policy already narrows the command allowlist, so without it the
    reason a control is unavailable on a given PC is written down nowhere.
    Provenance, so it sits in the reference column, not the triage band.

    Its route returns an EMPTY field list rather than the feature-gate 404 when
    policies are disabled. The panel contract hides a card with no fields, so
    it disappears on sites that have not enabled the feature - a 404 would have
    rendered a broken card on most of them. That was flagged in the design
    review as a snag; this is the fix.

  - Refresh Status and Restart Computer moved out of the page header into the
    triage band. They act on the MACHINE; Print Label, Edit, Retire and Back to
    List act on the RECORD, and mixing them put the most consequential button
    on the page - it halts whatever machine the PC serves - between printing a
    sticker and editing a form. In the band it sits beside the liveness chip
    and the machine it would take down.

    The band takes them through an 'actions' slot rather than knowing about PC
    verbs, so a measuring tool can put 'Calibrate' there without the core
    component learning anything about computers.

Full suite 2124 passed, vitest 260 passed, gates green, spec at 333 paths.
2026-08-28 23:00:25 -04:00
cproudlock
414a7243d7 Merge events and commands into one Activity feed, and lead with live state
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m9s
Two changes to the PC page, both about what a responder meets first.

LIVE STATE FIRST. The contributed panels moved to the head of the card flow.
The grid is a two-column FLOW (column-count, with display:contents on the
column wrappers), so a card cannot be pinned to a side and only document order
decides where it lands - putting the panels first fills the left column with
endpoint status, services, processes, interfaces and activity, and leaves
identity, hardware, network and location to the right. Panels still self-order
among themselves by declared position and still self-hide when no plugin
answers for the asset type.

ONE ACTIVITY FEED replaces the Recent events card. Events and commands answer
the same question - what happened to this box - and reading them as two lists
meant interleaving by eye. GET /api/endpoints/asset/<id>/activity merges them
newest-first, capped at 15. Both are keyed by assetid, so it is two indexed
reads and a merge sort rather than a join, and it renders through the existing
ADR-010 list renderer with no bespoke frontend work. A failed command is marked
critical, because that is the row somebody needs to notice. Not collapsed by
default, unlike the card it replaces.

The core AuditLog is deliberately NOT a third source: its rows carry the action
as the entity name and the asset only inside a details string, so including it
would mean parsing prose to find out which machine a row belongs to.

Requester names are withheld from callers who cannot themselves issue commands,
via caller_holds. A read-only viewer - a guest, on a site that grants this
panel - gets the history without a roster of who restarted what, and the lookup
is skipped entirely rather than fetched and discarded. requestedby holds a user
ID, so names resolve through one batched query, not one per row.

Verified on dev telemetry: a queued command shows as 'Collect now / Pending by
admin' above the Windows events, ordered by time.

Full suite 2124 passed, vitest 260 passed, naming/contract/parity gates green,
spec regenerated at 332 paths.
2026-08-28 22:34:59 -04:00
cproudlock
0f9ffdb2f1 Put the 6am answers at the top of the PC page, and stop saying things twice
Adds a shared triage band to the PC detail hero: is it alive, what stops
without it, how do I reach it.

'Serves machine' is the reason it exists. On a shop floor a PC matters because
a machine stops without it, and that fact was buried in the generic
relationships card at the bottom of the page. Both edge directions are read,
deduplicated, and filtered to assets typed 'machine' - production holds
reciprocal rows, so reading one direction shows the link on half the pages and
not the other half.

The band is a CORE component, not a PCDetail block. Five asset detail pages
already rebuild the same hero and grid independently, which is how they drifted
apart; machines, printers and network devices can drop this in rather than
growing a sixth copy. Liveness is passed in rather than fetched, because only a
plugin that collects telemetry can answer it and a site may not have one.

The status chip's verdict comes from the SERVER: /api/endpoints/<id> now
returns the same reporting/quiet/never it computes for the fleet list. The
quiet window is a site setting, so a threshold guessed in the browser would
disagree with the Fleet list about the same endpoint the moment a site changed
it.

Two cards removed from the body:

  - Remote Access duplicated the band's chips. It is what a responder clicks
    first, so it belongs above the fold, once.
  - Status was worse than duplication. It reads the COMPUTER RECORD, while the
    endpoint panel beside it reads live agent telemetry, and on an
    agent-managed PC the record fields sit empty - so the page rendered 'Last
    Reported: Never' a few inches from live telemetry saying otherwise. It now
    renders only when that record actually holds something, which is the case
    on sites running the asset-report script instead of the agent.

Verified against dev telemetry: band shows 'Reporting, 1 h ago', the machine it
serves, and its access chips; both duplicate cards are gone.

vitest 260 passed, naming/contract/parity gates green.
2026-08-28 22:21:22 -04:00
cproudlock
4c43c4e1ba Fix the Fleet tile and health colours in dark mode
Two colour bugs on the same page, both invisible in light mode.

A button does not inherit color the way it inherits font, so the plain
Endpoints total rendered in the browser default black on the dark theme's card.
The coloured tiles set their own color, which is why only that one showed it.

The coloured ones were wrong too, just less obviously: dark mode redefines
--success and --warning to bright variants but leaves --success-dark and
--warning-dark tuned for a white background, so those numbers and the health
pills came out dark green and dark amber on a dark blue card.

Scoped to the stamped [data-theme="dark"] rather than a prefers-color-scheme
query, matching the note in style.css: the theme store always stamps the
attribute, and a bare media query leaks dark styling into light mode on a
dark-OS machine.
2026-08-28 22:15:09 -04:00
cproudlock
54f0857e13 Split the fleet roster from per-endpoint reads, and stop offering controls nobody can use
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 14s
CI / migrations-mysql (push) Successful in 45s
CI / backend (push) Successful in 4m34s
Twelve endpoints routes shared one permission, which made a reasonable policy
impossible to express. A site that wants signed-out visitors to see live status
on a PC page had to hand them the entire fleet - including every logged-in
username on the floor - because the per-asset panels and the fleet-wide list
were gated on the same string.

endpoints.fleet now gates the list and the three dashboard routes.
endpoints.view keeps the per-asset reads: status, services, processes,
interfaces, events, command history.

The name is load-bearing. guest_grantable_permissions() opens any '.view' or
'.read' permission to the guest role, so calling this one 'endpoints.fleet'
rather than 'endpoints.fleetview' is what keeps the roster off the public
surface while per-asset telemetry stays grantable. Verified both ways.

Also: the services panel emitted Start/Stop row actions to every reader,
because row_actions_for() never consulted the caller. Clicking was always
refused server-side, so nothing was exploitable, but showing a read-only viewer
controls over a shop-floor PC is the wrong signal - and it stops being cosmetic
the moment a site grants that panel to guests.

That needed a contract addition rather than a local check: caller_holds() on
shopdb.api answers whether THIS caller holds a permission without refusing the
request, which is the complement to require_permission for a surface that
serves everyone but should only offer what the caller can act on. Guests are
included, since a signed-out visitor holds whatever the guest role was granted.
Reaching into shopdb.utils.authz instead would repeat the ADR-002 violation the
contract gate caught earlier in this branch. Contract 0.24.0 -> 0.25.0.

DEPLOYMENT: endpoints.fleet is a new permission. Run 'flask seed permissions'
and grant it to the roles that should keep seeing the Fleet page. Until then
that page is admin-only, since admins bypass every check - a safe direction,
but not an automatic one.

Full suite 2124 passed, naming and contract gates green, map and spec
regenerated.
2026-08-28 21:14:27 -04:00
cproudlock
4ef22b6579 Make the Fleet list answer the question it exists for
The fleet list showed hostname, user, uptime, CPU, memory and last-reported,
sorted by last-reported descending. Two of those columns have never held a
value, and that sort floats the healthiest endpoints to the top and buries the
ones somebody needs to act on.

What a shop-floor fleet view is actually for is which MACHINE stops if a PC is
dead, so that is now a column. Both edge directions are checked, deduplicated,
and constrained to assets typed 'machine' - without that last filter the column
filled up with office locations that happen to carry a controls edge.

Also here:

  - Sorted WORST FIRST: never reported, then quiet, then reporting. Expressed
    as a sortable SQL rank rather than a label applied after the fact, so the
    order survives pagination.
  - Health rollup as a PHRASE, not a score: 'eDNC stopped', '2 critical'. A
    percentage does not tell an operator what to do.
  - Filters for status, PC type, location and business unit; a sort parameter
    (the route accepted none); and search widened from hostname alone to asset
    number and the machine number a PC serves, which is how people actually
    look a bay up.
  - CPU and Memory columns dropped. The agent declares those fields and has
    never assigned them, so they rendered '-' on every row.
  - A summary strip wired to the dashboard routes, which existed and which
    nothing called.

Two bugs found while building it:

The health rollup first read a 'running' boolean, but service rows carry a
STATE STRING and the vocabulary varies by source, so every service would have
reported as stopped. The panel and the rollup now share one _service_running()
helper; two copies of that rule would drift.

The summary counted quiet as total - reporting, which folded never-reported
endpoints in with quiet ones. Those are different failures - an enrolment or
key problem versus a machine that went down - so 'never' is counted separately.

Per-page batching for machines and critical-event counts: three queries per
page rather than two per row.

Full suite 2124 passed, vitest 260 passed, naming and contract gates green.
2026-08-28 21:00:35 -04:00
cproudlock
af42ba3589 Register the endpoints list without a trailing slash, and stop hiding its errors
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Successful in 4m7s
An endpoint reported telemetry correctly for hours while the Fleet page showed
"No endpoints found". The PC detail page displayed its panels the whole time, so
ingest was plainly working; only the list was empty.

The list route was the one route in 331 paths registered with a trailing slash:

    @endpoints_bp.route('/', methods=['GET'])      ->  /api/endpoints/

The browser client calls it without one, so Flask answered a 308 redirect to an
ABSOLUTE url. On dev that resolves and nobody notices. Behind the IIS subpath it
cannot: with no ProxyFix, Werkzeug builds that Location from the scheme and host
it believes it has, so it emitted http:// and dropped the /shopdb prefix. A
browser will not follow an https page to http, the request failed, and the
page's catch block rendered the empty state.

Every other route in the plugin worked throughout, because none of them carries
a trailing slash - the detail panels call /endpoints/asset/<id>/status and were
never redirected. Detail works, list empty is the signature of this bug.

Registered as '' now, so the rule is /api/endpoints. That is what
CONTRIBUTING.md's API section spells and what every other list route in the
product already did. The inventory and the generated spec are updated to match.

Also here:

  - tests/test_core/test_no_redirecting_routes.py fails the build if any /api
    rule is registered with a trailing slash and strict slashes. Verified by
    planting the violation: it named the offending route, then passed once
    reverted. It takes the shared app fixture rather than building its own
    application - doing that rebinds SQLAlchemy and leaves the NEXT module
    without tables, which showed up as three unrelated "no such table: settings"
    failures visible only in a full run.

  - The Fleet page now renders "Could not load endpoints (HTTP nnn). This is a
    failed request, not an empty fleet." A list that reports a 403 and an empty
    fleet identically is what made this take as long as it did.

  - TRUSTED_PROXY_COUNT (default 0) applies ProxyFix when set. Off by default on
    purpose: it makes X-Forwarded-* trusted, and a client can send those itself,
    while the login rate limit and account lockout are keyed on the client
    address. Set it to 1 on an instance behind IIS.

Full suite 2124 passed, vitest 260 passed, naming and docs gates green.
2026-08-28 17:48:06 -04:00
cproudlock
2aea2bd818 File the Fleet nav item under Assets, beside the PCs it describes
Some checks failed
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 40s
CI / backend (push) Has been cancelled
The endpoints plugin declared a section of its own, so the sidebar rendered a
one-item 'Fleet' group at the bottom, below Information, far from the PCs the
view is about. Its own docstring said it belonged just after PCs, and its
position (16, against PCs at 15) already said so too; only the section
disagreed.

Filed under assets, which puts it directly under PCs. The now-empty fleet
section is removed from NAV_SECTIONS - an unused section is an invitation to
file something else there.
2026-08-28 16:20:28 -04:00
cproudlock
784964c907 Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.

What arrives:

  endpoints plugin  - a ShopDB agent reporting endpoint state, telemetry,
                      services and interfaces, with a command channel the agent
                      long-polls. Ingest stays on the generic collector route
                      (ADR-006); the agent key is a UI-managed single-scope
                      token whose revocation is an instant kill switch.
  computers policies - Configuration and Business Policies (RMM-style), scope
                      trees validated at write time, actions checked against
                      the endpoints command allowlist. Feature-gated: every
                      route 404s while the setting is off, so enabling it can
                      never half-expose the surface.
  asset CSV import  - templates, a collector script carrying the site path, and
                      a validate-then-commit import. A plugin can own a virtual
                      column plus a post-write row hook, which is how a PC's
                      machine number becomes a controls link without a
                      hand-built relationships sheet.
  agent/            - the Windows agent and its installer.

Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.

MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).

Review fixes carried on top:

  - six policy GETs were jwt_required(optional=True), which authenticates a
    caller without checking anything; they require computers.view now, and six
    redundant jwt_required decorators are gone
  - the agent installer took a default BaseUrl and CollectorKey, so a mistyped
    run silently pointed an endpoint at the wrong instance; both are mandatory
    and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
  - a committing CSV import wrote no audit row

Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.

Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
2026-08-28 16:17:17 -04:00