Site branch tooling, branch-aware publication, dashboard fixes

Publication now mirrors the branch and writes one bundle per branch. Dashboard:
guests see what they can reach rather than a blank page, and the estate total
lists every type it counts.
This commit is contained in:
2026-08-30 11:19:00 -04:00
parent 870e068c44
commit 43fed63277
4 changed files with 139 additions and 16 deletions

6
.gitattributes vendored
View File

@@ -4,3 +4,9 @@
*.sh text eol=lf
scripts/check-naming-and-style.sh text eol=lf
.githooks/pre-commit text eol=lf
# Batch files must be CRLF. cmd.exe parses a lone LF inconsistently around
# labels and goto, so an LF-only .cmd can run its happy path and then jump
# nowhere on the error path - which is the path that matters in a promote
# script.
*.cmd text eol=crlf

View File

@@ -151,6 +151,51 @@ Tests are required for:
- New plugin hooks (contract test)
- Schema migrations (before/after data integrity)
## 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.
**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
(`scripts/site_imports/<site>/`, which the style gate exempts). That is the only
site-specific home in the tree, so a site branch is about TIMING, not locality:
things that are not ready yet, rather than things that are permanently ours.
Say which at commit time, with a trailer, because the decision does not survive
being deferred - months later it is a list of subject lines and a guess:
```bash
git commit -m "Add the thing" -m "Scope: product" # main should have it
git commit -m "Add the thing" -m "Scope: site" # this site alone
```
Then:
```bash
tools/wj-status.sh # what is unpromoted, and where it belongs
tools/promote-to-main.sh --dry-run <sha> # what files would reach main
tools/promote-to-main.sh <sha> [<sha> ...] # oldest first; runs main's gates
tools/git-push.sh main # you push, deliberately
```
Windows equivalents are `tools\git-push.cmd` and `tools\promote-to-main.cmd`.
Promotion cherry-picks NAMED commits rather than merging the branch. Merging
takes the site work along with the product work, which is how a site branch
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.
So rebase the site branch on `main` often, and promote as you go rather than in
one batch at the end.
## Code review checklist
- [ ] No underscores in table or column names

View File

@@ -1,5 +1,5 @@
<template>
<div v-if="hasBoard" class="dc-board">
<div class="dc-board">
<!-- The rollup. Counts ROWS, not cards, and each count filters the queue
below rather than navigating away: landing on a number that tells you
@@ -54,7 +54,8 @@
<!-- ONE queue, worst first, instead of a card per source. Reading order
is work order. -->
<section v-if="queue.length || overflow.length" class="dc-panel dc-queue">
<section v-if="canseechecks && (queue.length || overflow.length)"
class="dc-panel dc-queue">
<header class="dc-panel-head">
<h3>Needs attention</h3>
<span class="dc-panel-count">{{ queueTotal }}</span>
@@ -78,7 +79,12 @@
</p>
</section>
<section v-else class="dc-panel dc-clear">
<!-- Only claim the board is clear to somebody who can actually see the
checks. A reader with no permission for any of them is not looking at
a healthy fleet, they are looking at nothing - and telling them
"nothing needs attention" is the same lie as reporting Low Supplies 0
while the monitoring is down. -->
<section v-else-if="canseechecks" class="dc-panel dc-clear">
<p class="dc-clear-line">Nothing needs attention.</p>
</section>
@@ -195,7 +201,11 @@ const context = computed(
const queueTotal = computed(
() => queue.value.length + overflow.value.reduce((sum, e) => sum + e.hidden, 0))
const hasBoard = computed(() => liveCards.value.length > 0)
// How many cards this caller is ALLOWED to see, independent of whether they
// currently hold rows. Zero means no visibility into exceptions at all, which
// must not be drawn as "nothing wrong".
const permittedcount = ref(0)
const canseechecks = computed(() => permittedcount.value > 0)
function toggleFilter(level) {
filter.value = filter.value === level ? '' : level
@@ -237,6 +247,8 @@ async function load() {
const wanted = renderableCards(
permittedCards(declared, (name) => auth.hasPermission(name)))
permittedcount.value = wanted.length
cards.value = await Promise.all(wanted.map(async (card) => {
try {
const response = await api.get(toApiPath(card.endpoint))

View File

@@ -16,19 +16,27 @@
an exception and DashboardCards has no business declaring one. -->
<DashboardCards>
<template #rail>
<section class="dc-panel estate">
<!-- 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
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>
</header>
<!-- One line per asset type, then the total under a rule.
These used to be seven equal tiles in which four of them summed
to a fifth: machines + PCs + printers + network IS all assets,
shown beside its own addends at the same weight. A total that
looks like a sixth category invites people to add it in again.
Two of the seven also counted STATUS rather than type without
saying so. -->
These used to be seven equal tiles in which some of them summed
to another: the type counts ARE all assets, shown beside their
own total at the same weight. A total that looks like another
category invites people to add it in again. Two of the seven
also counted STATUS rather than type without saying so.
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>
@@ -45,6 +53,16 @@
<router-link to="/network" class="estate-k">Network devices</router-link>
<span class="estate-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
stopped matching the rows above it - 610 of rows over a stated
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>
<div class="estate-row estate-total">
<span class="estate-k">All assets</span>
@@ -62,6 +80,23 @@
</span>
</p>
</section>
<!-- Never leave the page blank, and do not apologise either: show
what this reader CAN open. A guest who can see none of the cards
and cannot read the totals previously got a heading and nothing
else, which reads as a broken site rather than a closed door.
The list is the same permission-filtered navigation the sidebar
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">
<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>
</section>
</template>
</DashboardCards>
</template>
@@ -84,21 +119,46 @@
// needed a person put together. A filtered "recently changed" view belongs in
// Reports.
import DashboardCards from '@/components/DashboardCards.vue'
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { dashboardApi } from '../api'
const loading = ref(true)
const stats = ref({})
// Whether the totals actually came back. Swallowing the error and rendering
// stats as {} turned a 403 into a column of confident zeros.
const summaryloaded = ref(false)
// Where this reader can actually go, for the case where the board has nothing
// for them. Same source as the sidebar, so it is already filtered to what they
// hold - and to what the guest role reaches, when nobody is signed in.
const navitems = ref([])
const openable = computed(
// The dashboard itself is not somewhere to go from the dashboard.
() => navitems.value.filter((item) => item.route && item.route !== '/'))
onMounted(async () => {
try {
const response = await dashboardApi.summary().catch(() => ({ data: { data: {} } }))
const response = await dashboardApi.summary()
stats.value = response.data.data || {}
summaryloaded.value = Object.keys(stats.value).length > 0
} catch (error) {
console.error('Dashboard load error:', error)
} finally {
loading.value = false
// A caller without assets.view is restricted, not broken, so this is not
// logged as an error - the panel offers them somewhere to go instead.
summaryloaded.value = false
}
if (!summaryloaded.value) {
// Only fetched when it is needed, so the common case costs nothing.
try {
const response = await dashboardApi.navigation()
navitems.value = response.data.data || []
} catch (error) {
navitems.value = []
}
}
loading.value = false
})
</script>