Merge main into wj before taking wilm's work

wj held nothing main lacked - git cherry marks every one of its commits as
already present - but promotion cherry-picks, so the histories diverged and a
fast-forward is not possible. Merging rather than resetting keeps the branch
recoverable and needs no force-push.

Levelling first so wilm's patches apply against current product code.

# Conflicts:
#	CONTRIBUTING.md
#	frontend/src/views/Dashboard.vue
#	tools/wj-status.sh
This commit is contained in:
cproudlock
2026-08-30 15:19:02 -04:00
6 changed files with 168 additions and 62 deletions

View File

@@ -153,9 +153,15 @@ Tests are required for:
## Branches: what belongs on main, and what does not ## Branches: what belongs on main, and what does not
Two branches. `main` is the product every site runs and the publication mirror `main` is the product every site runs and the publication mirror exports.
exports. `<site>` branches - currently `wj` - hold work that is not ready for `<site>` branches hold work that is not ready for other sites to see. There are
other sites to see. two: `wj` here, and `wilm` on the corporate GitHub, where the other site
develops the .NET endpoint agent.
A site branch is not a fork. Both feed `main`, and the sooner they do it the
cheaper it is. A branch the dev box cannot see is still a branch: `wilm` is not
visible from here because this machine has no GitHub account, which is why
`fetch-wilm-branch.cmd` on the share exists to bring it back for review.
**Most work is product.** ADR-015 gives site differences three homes on `main`: **Most work is product.** ADR-015 gives site differences three homes on `main`:
a setting with a neutral default, seed data, or a site-namespaced directory a setting with a neutral default, seed data, or a site-namespaced directory
@@ -188,10 +194,9 @@ quietly becomes the product.
**The failure to watch for is not what you think.** Promoting something **The failure to watch for is not what you think.** Promoting something
site-specific by accident is caught by the ADR-015 gate on the way into `main`. site-specific by accident is caught by the ADR-015 gate on the way into `main`.
The real risk is product work sitting on a site branch forever because nobody The real risk is product work sitting on a site branch until bringing it back is
decided - which is how the `wilm` branch stopped being a merge and became a a port rather than a merge - one such round trip cost a MySQL dialect fix, a
port, costing a MySQL dialect fix, a contract violation and a plugin install to contract violation and a plugin install.
bring back.
So rebase the site branch on `main` often, and promote as you go rather than in So rebase the site branch on `main` often, and promote as you go rather than in
one batch at the end. one batch at the end.

View File

@@ -90,6 +90,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
## Size ## Size
- test functions defined: **1503** (parametrised cases collect higher) - test functions defined: **1507** (parametrised cases collect higher)
- documented API paths: **333** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) - documented API paths: **333** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)

View File

@@ -18,13 +18,13 @@
<template #rail> <template #rail>
<!-- Only when the totals actually loaded. /api/dashboard/summary <!-- Only when the totals actually loaded. /api/dashboard/summary
needs assets.view, and a caller without it got an empty object needs assets.view, and a caller without it got an empty object
that rendered as a tidy column of zeros - an estate of nothing, that rendered as a tidy column of zeros - a site with nothing
stated with total confidence. Same fault as reporting Low in it, stated with total confidence. Same fault as reporting Low
Supplies 0 while the monitoring is down. --> Supplies 0 while the monitoring is down. -->
<section v-if="summaryloaded" class="dc-panel estate"> <section v-if="summaryloaded" class="dc-panel assets">
<header class="estate-head"> <header class="assets-head">
<h3>Estate</h3> <h3>Assets</h3>
<router-link to="/reports" class="estate-link">Reports</router-link> <router-link to="/reports" class="assets-link">Reports</router-link>
</header> </header>
<!-- One line per asset type, then the total under a rule. <!-- One line per asset type, then the total under a rule.
@@ -37,21 +37,21 @@
EVERY counted type must appear here. The endpoint's grand total EVERY counted type must appear here. The endpoint's grand total
is machines + PCs + network + printers + measuring tools, and is machines + PCs + network + printers + measuring tools, and
omitting one leaves a total nothing on screen explains. --> omitting one leaves a total nothing on screen explains. -->
<div class="estate-row"> <div class="assets-row">
<router-link to="/machines" class="estate-k">Machines</router-link> <router-link to="/machines" class="assets-k">Machines</router-link>
<span class="estate-v">{{ stats.totalmachines || 0 }}</span> <span class="assets-v">{{ stats.totalmachines || 0 }}</span>
</div> </div>
<div class="estate-row"> <div class="assets-row">
<router-link to="/pcs" class="estate-k">PCs</router-link> <router-link to="/pcs" class="assets-k">PCs</router-link>
<span class="estate-v">{{ stats.totalpc || 0 }}</span> <span class="assets-v">{{ stats.totalpc || 0 }}</span>
</div> </div>
<div class="estate-row"> <div class="assets-row">
<router-link to="/printers" class="estate-k">Printers</router-link> <router-link to="/printers" class="assets-k">Printers</router-link>
<span class="estate-v">{{ stats.totalprinter || 0 }}</span> <span class="assets-v">{{ stats.totalprinter || 0 }}</span>
</div> </div>
<div class="estate-row"> <div class="assets-row">
<router-link to="/network" class="estate-k">Network devices</router-link> <router-link to="/network" class="assets-k">Network devices</router-link>
<span class="estate-v">{{ stats.totalnetwork || 0 }}</span> <span class="assets-v">{{ stats.totalnetwork || 0 }}</span>
</div> </div>
<!-- Measuring tools are IN the grand total the endpoint returns, so <!-- Measuring tools are IN the grand total the endpoint returns, so
they have to be visible here. Left out, the total silently they have to be visible here. Left out, the total silently
@@ -59,24 +59,34 @@
729 on an instance with gages - which is the exact fault this 729 on an instance with gages - which is the exact fault this
panel was built to remove. Dev has none, so it looked correct panel was built to remove. Dev has none, so it looked correct
here and was wrong everywhere the import had run. --> here and was wrong everywhere the import had run. -->
<div class="estate-row"> <div class="assets-row">
<router-link to="/measuringtools" class="estate-k">Measuring tools</router-link> <router-link to="/measuringtools" class="assets-k">Measuring tools</router-link>
<span class="estate-v">{{ stats.totalmeasuringtool || 0 }}</span> <span class="assets-v">{{ stats.totalmeasuringtool || 0 }}</span>
</div> </div>
<div class="estate-row estate-total"> <div class="assets-row assets-total">
<span class="estate-k">All assets</span> <span class="assets-k">All assets</span>
<span class="estate-v">{{ stats.totalassets || 0 }}</span> <span class="assets-v">{{ stats.totalassets || 0 }}</span>
</div> </div>
<!-- Status, on one line, and explicitly labelled as status. In <!-- Every status the total contains, not just In Use. These were
repair is muted at zero rather than given a card of its own to counted server-side for the rollup and then thrown away, so a
announce that nothing is wrong. --> reader could see one number and had no way to learn where the
<p class="estate-status"> rest of the total went. Retired assets in particular ARE in
<span class="estate-inuse">{{ stats.activeassets || 0 }}</span> in use the total, which is not guessable. -->
<span aria-hidden="true"> / </span> <div v-for="row in statusrows" :key="row.name" class="assets-row assets-sub">
<span :class="{ 'estate-zero': !stats.inrepair }"> <span class="assets-k assets-subk">{{ row.name }}</span>
{{ stats.inrepair || 0 }} in repair <span class="assets-v assets-subv">{{ row.count }}</span>
</div>
<!-- What the total covers. Asked twice what the number meant, which
is the signal a label is underspecified. -->
<p class="assets-caption">
Active records only<span v-if="stats.dualpathcollapsed">, dual-bay
pairs counted once</span>.
<span v-if="stats.inactiveexcluded">
{{ stats.inactiveexcluded }} deactivated record{{ stats.inactiveexcluded === 1 ? '' : 's' }}
excluded.
</span> </span>
</p> </p>
</section> </section>
@@ -89,12 +99,12 @@
uses - server-filtered for a signed-out visitor - so it can never uses - server-filtered for a signed-out visitor - so it can never
offer a link that dead-ends at the router guard, and it names no offer a link that dead-ends at the router guard, and it names no
plugin this page had to know about. --> plugin this page had to know about. -->
<section v-else-if="openable.length" class="dc-panel estate"> <section v-else-if="openable.length" class="dc-panel assets">
<header class="estate-head"> <header class="assets-head">
<h3>Where you can go</h3> <h3>Where you can go</h3>
</header> </header>
<div v-for="item in openable" :key="item.route" class="estate-row"> <div v-for="item in openable" :key="item.route" class="assets-row">
<router-link :to="item.route" class="estate-k">{{ item.name }}</router-link> <router-link :to="item.route" class="assets-k">{{ item.name }}</router-link>
</div> </div>
</section> </section>
</template> </template>
@@ -133,6 +143,17 @@ const summaryloaded = ref(false)
// hold - and to what the guest role reaches, when nobody is signed in. // hold - and to what the guest role reaches, when nobody is signed in.
const navitems = ref([]) const navitems = ref([])
// Every status in the total, biggest first. Sorted rather than fixed-order
// because the statuses are seed data a site can change - hardcoding "In Use,
// In Repair, Retired" would silently omit a status somebody added.
const statusrows = computed(() => {
const statuses = stats.value.statuses || {}
return Object.keys(statuses)
.map((name) => ({ name, count: statuses[name] }))
.filter((row) => row.count > 0)
.sort((a, b) => b.count - a.count)
})
const openable = computed( const openable = computed(
// The dashboard itself is not somewhere to go from the dashboard. // The dashboard itself is not somewhere to go from the dashboard.
() => navitems.value.filter((item) => item.route && item.route !== '/')) () => navitems.value.filter((item) => item.route && item.route !== '/'))
@@ -165,7 +186,7 @@ onMounted(async () => {
<style scoped> <style scoped>
/* Matches the panels in DashboardCards; the rail reads as one column rather /* Matches the panels in DashboardCards; the rail reads as one column rather
than as a page card that happens to sit beside them. */ than as a page card that happens to sit beside them. */
.estate { .assets {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
@@ -173,13 +194,13 @@ onMounted(async () => {
min-width: 0; min-width: 0;
} }
.estate-head { .assets-head {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 0.35rem; margin-bottom: 0.35rem;
} }
.estate-head h3 { .assets-head h3 {
margin: 0; margin: 0;
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 700; font-weight: 700;
@@ -188,14 +209,14 @@ onMounted(async () => {
color: var(--text-light); color: var(--text-light);
flex: 1 1 auto; flex: 1 1 auto;
} }
.estate-link { .assets-link {
font-size: 0.78rem; font-size: 0.78rem;
color: var(--link); color: var(--link);
text-decoration: none; text-decoration: none;
} }
.estate-link:hover { text-decoration: underline; } .assets-link:hover { text-decoration: underline; }
.estate-row { .assets-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
@@ -203,32 +224,39 @@ onMounted(async () => {
padding: 0.35rem 0; padding: 0.35rem 0;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.estate-row:last-of-type { border-bottom: none; } .assets-row:last-of-type { border-bottom: none; }
.estate-k { .assets-k {
color: var(--link); color: var(--link);
text-decoration: none; text-decoration: none;
font-size: 0.9rem; font-size: 0.9rem;
} }
a.estate-k:hover { text-decoration: underline; } a.assets-k:hover { text-decoration: underline; }
span.estate-k { color: var(--text); font-weight: 600; } span.assets-k { color: var(--text); font-weight: 600; }
.estate-v { .assets-v {
font-weight: 600; font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.estate-total { .assets-total {
margin-top: 0.35rem; margin-top: 0.35rem;
border-top: 2px solid var(--border); border-top: 2px solid var(--border);
border-bottom: none; border-bottom: none;
} }
.estate-status { /* Status rows sit UNDER the total, indented and quieter, because they break
margin: 0.5rem 0 0; the total down rather than adding to it. Same weight as the type rows would
font-size: 0.85rem; read as more things to add up. */
.assets-sub { border-bottom: none; padding: 0.15rem 0 0.15rem 0.75rem; }
.assets-subk { color: var(--text-light); font-size: 0.85rem; }
a.assets-subk:hover { text-decoration: none; }
.assets-subv { font-weight: 500; font-size: 0.85rem; color: var(--text-light); }
.assets-caption {
margin: 0.6rem 0 0;
font-size: 0.78rem;
line-height: 1.45;
color: var(--text-light); color: var(--text-light);
} }
.estate-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; }
.estate-zero { color: var(--text-light); }
</style> </style>

View File

@@ -38,6 +38,19 @@ def _count_by_type(assettype):
return query.count() return query.count()
def _inactive_excluded():
"""Counted-type assets held back by isactive, i.e. soft-deleted.
Reported so the page can account for itself. A total that silently drops
rows is the reason somebody compares it with a list page and finds a
difference they cannot explain.
"""
return (db.session.query(Asset).join(AssetType)
.filter(Asset.isactive == False,
AssetType.assettype.in_(COUNTEDTYPES))
.count())
COUNTEDTYPES = ('machine', 'computer', 'network_device', 'printer', COUNTEDTYPES = ('machine', 'computer', 'network_device', 'printer',
'measuring_tool') 'measuring_tool')
@@ -69,6 +82,10 @@ def _countedassets():
@require_permission('assets.view') @require_permission('assets.view')
def get_dashboard(): def get_dashboard():
"""Get dashboard summary data (asset-based).""" """Get dashboard summary data (asset-based)."""
# Imported here, as in the helpers above: the dualpath service reaches back
# into models, so a module-level import is circular.
from shopdb.core.services.dualpath import dualpath_single_machine_enabled
machine_count = _count_by_type('machine') machine_count = _count_by_type('machine')
pc_count = _count_by_type('computer') pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device') network_count = _count_by_type('network_device')
@@ -99,6 +116,22 @@ def get_dashboard():
'totalmeasuringtool': measuringtool_count, 'totalmeasuringtool': measuringtool_count,
'activeassets': status_dict.get('In Use', 0), 'activeassets': status_dict.get('In Use', 0),
'inrepair': status_dict.get('In Repair', 0), 'inrepair': status_dict.get('In Repair', 0),
# What the totals actually cover, so the page can say so rather than
# leaving a reader to work it out from a number that surprised them.
# Every count here filters isactive, and the dual-path collapse is a
# per-site toggle - asserting it unconditionally would be false at a
# site that has it off.
'countsactiveonly': True,
'dualpathcollapsed': bool(dualpath_single_machine_enabled()),
# Every status, not just the two that had callers. The counts were
# already built for the rollup above and then thrown away, so a reader
# could see "In Use" and had no way to learn where the rest went.
'statuses': status_dict,
# A DIFFERENT thing to a status, and the one that makes a total
# disagree with a list page. isactive is the soft-delete flag: these
# rows are excluded from every count above, so they are in no status
# bucket and cannot be found by adding the buckets up.
'inactiveexcluded': _inactive_excluded(),
# Structured data # Structured data
'counts': { 'counts': {
'machines': machine_count, 'machines': machine_count,

View File

@@ -261,6 +261,31 @@ echo "bundle: $BUNDLE"
# --- 6. optional per-instance dists (both bases: /ops = dev, /shopdb = prod) --- # --- 6. optional per-instance dists (both bases: /ops = dev, /shopdb = prod) ---
# Two instances run on the box; each needs its own base-path build. Always # Two instances run on the box; each needs its own base-path build. Always
# rebuild BOTH so prod never ships a stale frontend. # rebuild BOTH so prod never ships a stale frontend.
# --dist IS MAIN ONLY.
#
# These paths are not branch-qualified and cannot safely be: update-prod-server
# .cmd robocopies frontend-dist-subpath-shopdb onto the production server with
# /MIR, a full mirror, and has no idea which branch built it. So --dist from a
# site branch would replace production's frontend with unreleased work at the
# next deploy, silently, and the deploy script could not tell.
#
# Branch-qualifying the directory would leave the same trap for whoever pointed
# a deploy at the wrong one. Production runs the product; a site branch has no
# business producing the artefact production installs.
if [ "$BUILD_DIST" = 1 ] && [ "$BRANCH" != "main" ]; then
echo "REFUSED: --dist on branch '$BRANCH'." >&2
echo >&2
echo "The dist directories are what update-prod-server.cmd mirrors onto the" >&2
echo "production server, and they are not branch-qualified. Building them" >&2
echo "from '$BRANCH' would put unreleased work on production at the next" >&2
echo "deploy, with nothing to reveal it." >&2
echo >&2
echo "Promote the commits to main and export from there:" >&2
echo " tools/wj-status.sh" >&2
echo " tools/promote-to-main.sh <sha> ..." >&2
exit 1
fi
if [ "$BUILD_DIST" = 1 ]; then if [ "$BUILD_DIST" = 1 ]; then
cd "$WORK/frontend" cd "$WORK/frontend"
for base in ops shopdb; do for base in ops shopdb; do

View File

@@ -44,10 +44,25 @@ if ! git rev-parse --verify --quiet refs/heads/wj >/dev/null; then
exit 1 exit 1
fi fi
COMMITS="$(git rev-list --reverse "$RANGE")" # git cherry, not rev-list. Promotion CHERRY-PICKS, which gives the commit a new
# sha on main, so `main..wj` keeps listing work that already landed - and this
# tool would then invite you to promote it a second time. `git cherry` compares
# patch content and marks an already-present commit with a leading '-'.
COMMITS="$(git cherry main wj | awk '$1 == "+" { print $2 }')"
ALREADY="$(git cherry main wj | awk '$1 == "-" { print $2 }' | wc -l)"
if [ -z "$COMMITS" ]; then if [ -z "$COMMITS" ]; then
echo "Nothing on wj that main does not have." echo "Nothing on wj that main does not have."
if [ "$ALREADY" -gt 0 ]; then
echo "($ALREADY commit(s) here have already landed on main as cherry-picks.)"
fi
echo
echo "wj is behind main by:"
git log --oneline wj..main | sed 's/^/ /' | head -10
echo
echo "Rebase it before starting new work:"
echo " git checkout wj && git rebase main"
exit 0 exit 0
fi fi