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
Two branches. `main` is the product every site runs and the publication mirror
exports. `<site>` branches - currently `wj` - hold work that is not ready for
other sites to see.
`main` is the product every site runs and the publication mirror exports.
`<site>` branches hold work that is not ready for other sites to see. There are
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`:
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
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
decided - which is how the `wilm` branch stopped being a merge and became a
port, costing a MySQL dialect fix, a contract violation and a plugin install to
bring back.
The real risk is product work sitting on a site branch until bringing it back is
a port rather than a merge - one such round trip cost a MySQL dialect fix, a
contract violation and a plugin install.
So rebase the site branch on `main` often, and promote as you go rather than in
one batch at the end.

View File

@@ -90,6 +90,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
## 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`)

View File

@@ -18,13 +18,13 @@
<template #rail>
<!-- Only when the totals actually loaded. /api/dashboard/summary
needs assets.view, and a caller without it got an empty object
that rendered as a tidy column of zeros - an estate of nothing,
stated with total confidence. Same fault as reporting Low
that rendered as a tidy column of zeros - a site with nothing
in it, stated with total confidence. Same fault as reporting Low
Supplies 0 while the monitoring is down. -->
<section v-if="summaryloaded" class="dc-panel estate">
<header class="estate-head">
<h3>Estate</h3>
<router-link to="/reports" class="estate-link">Reports</router-link>
<section v-if="summaryloaded" class="dc-panel assets">
<header class="assets-head">
<h3>Assets</h3>
<router-link to="/reports" class="assets-link">Reports</router-link>
</header>
<!-- 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
is machines + PCs + network + printers + measuring tools, and
omitting one leaves a total nothing on screen explains. -->
<div class="estate-row">
<router-link to="/machines" class="estate-k">Machines</router-link>
<span class="estate-v">{{ stats.totalmachines || 0 }}</span>
<div class="assets-row">
<router-link to="/machines" class="assets-k">Machines</router-link>
<span class="assets-v">{{ stats.totalmachines || 0 }}</span>
</div>
<div class="estate-row">
<router-link to="/pcs" class="estate-k">PCs</router-link>
<span class="estate-v">{{ stats.totalpc || 0 }}</span>
<div class="assets-row">
<router-link to="/pcs" class="assets-k">PCs</router-link>
<span class="assets-v">{{ stats.totalpc || 0 }}</span>
</div>
<div class="estate-row">
<router-link to="/printers" class="estate-k">Printers</router-link>
<span class="estate-v">{{ stats.totalprinter || 0 }}</span>
<div class="assets-row">
<router-link to="/printers" class="assets-k">Printers</router-link>
<span class="assets-v">{{ stats.totalprinter || 0 }}</span>
</div>
<div class="estate-row">
<router-link to="/network" class="estate-k">Network devices</router-link>
<span class="estate-v">{{ stats.totalnetwork || 0 }}</span>
<div class="assets-row">
<router-link to="/network" class="assets-k">Network devices</router-link>
<span class="assets-v">{{ stats.totalnetwork || 0 }}</span>
</div>
<!-- Measuring tools are IN the grand total the endpoint returns, so
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
panel was built to remove. Dev has none, so it looked correct
here and was wrong everywhere the import had run. -->
<div class="estate-row">
<router-link to="/measuringtools" class="estate-k">Measuring tools</router-link>
<span class="estate-v">{{ stats.totalmeasuringtool || 0 }}</span>
<div class="assets-row">
<router-link to="/measuringtools" class="assets-k">Measuring tools</router-link>
<span class="assets-v">{{ stats.totalmeasuringtool || 0 }}</span>
</div>
<div class="estate-row estate-total">
<span class="estate-k">All assets</span>
<span class="estate-v">{{ stats.totalassets || 0 }}</span>
<div class="assets-row assets-total">
<span class="assets-k">All assets</span>
<span class="assets-v">{{ stats.totalassets || 0 }}</span>
</div>
<!-- Status, on one line, and explicitly labelled as status. In
repair is muted at zero rather than given a card of its own to
announce that nothing is wrong. -->
<p class="estate-status">
<span class="estate-inuse">{{ stats.activeassets || 0 }}</span> in use
<span aria-hidden="true"> / </span>
<span :class="{ 'estate-zero': !stats.inrepair }">
{{ stats.inrepair || 0 }} in repair
<!-- Every status the total contains, not just In Use. These were
counted server-side for the rollup and then thrown away, so a
reader could see one number and had no way to learn where the
rest of the total went. Retired assets in particular ARE in
the total, which is not guessable. -->
<div v-for="row in statusrows" :key="row.name" class="assets-row assets-sub">
<span class="assets-k assets-subk">{{ row.name }}</span>
<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>
</p>
</section>
@@ -89,12 +99,12 @@
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
plugin this page had to know about. -->
<section v-else-if="openable.length" class="dc-panel estate">
<header class="estate-head">
<section v-else-if="openable.length" class="dc-panel assets">
<header class="assets-head">
<h3>Where you can go</h3>
</header>
<div v-for="item in openable" :key="item.route" class="estate-row">
<router-link :to="item.route" class="estate-k">{{ item.name }}</router-link>
<div v-for="item in openable" :key="item.route" class="assets-row">
<router-link :to="item.route" class="assets-k">{{ item.name }}</router-link>
</div>
</section>
</template>
@@ -133,6 +143,17 @@ const summaryloaded = ref(false)
// hold - and to what the guest role reaches, when nobody is signed in.
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(
// The dashboard itself is not somewhere to go from the dashboard.
() => navitems.value.filter((item) => item.route && item.route !== '/'))
@@ -165,7 +186,7 @@ onMounted(async () => {
<style scoped>
/* Matches the panels in DashboardCards; the rail reads as one column rather
than as a page card that happens to sit beside them. */
.estate {
.assets {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
@@ -173,13 +194,13 @@ onMounted(async () => {
min-width: 0;
}
.estate-head {
.assets-head {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.estate-head h3 {
.assets-head h3 {
margin: 0;
font-size: 0.78rem;
font-weight: 700;
@@ -188,14 +209,14 @@ onMounted(async () => {
color: var(--text-light);
flex: 1 1 auto;
}
.estate-link {
.assets-link {
font-size: 0.78rem;
color: var(--link);
text-decoration: none;
}
.estate-link:hover { text-decoration: underline; }
.assets-link:hover { text-decoration: underline; }
.estate-row {
.assets-row {
display: flex;
align-items: baseline;
justify-content: space-between;
@@ -203,32 +224,39 @@ onMounted(async () => {
padding: 0.35rem 0;
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);
text-decoration: none;
font-size: 0.9rem;
}
a.estate-k:hover { text-decoration: underline; }
span.estate-k { color: var(--text); font-weight: 600; }
a.assets-k:hover { text-decoration: underline; }
span.assets-k { color: var(--text); font-weight: 600; }
.estate-v {
.assets-v {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.estate-total {
.assets-total {
margin-top: 0.35rem;
border-top: 2px solid var(--border);
border-bottom: none;
}
.estate-status {
margin: 0.5rem 0 0;
font-size: 0.85rem;
/* Status rows sit UNDER the total, indented and quieter, because they break
the total down rather than adding to it. Same weight as the type rows would
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);
}
.estate-inuse { color: var(--success); font-weight: 600; font-variant-numeric: tabular-nums; }
.estate-zero { color: var(--text-light); }
</style>

View File

@@ -38,6 +38,19 @@ def _count_by_type(assettype):
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',
'measuring_tool')
@@ -69,6 +82,10 @@ def _countedassets():
@require_permission('assets.view')
def get_dashboard():
"""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')
pc_count = _count_by_type('computer')
network_count = _count_by_type('network_device')
@@ -99,6 +116,22 @@ def get_dashboard():
'totalmeasuringtool': measuringtool_count,
'activeassets': status_dict.get('In Use', 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
'counts': {
'machines': machine_count,

View File

@@ -261,6 +261,31 @@ echo "bundle: $BUNDLE"
# --- 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
# 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
cd "$WORK/frontend"
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
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
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
fi