release(v1.7.27-alpha): mirror transparency — served-by line + one-click test button

- New "Served by {mirror}" line on the System Update page so operators can see
  which mirror actually served the available manifest (vs. which is configured
  primary). Backend threads the served URL through UpdateState.manifest_mirror.
- New update.test-mirror RPC + per-row lightning-bolt button that pings a
  mirror and renders reachable/latency or error inline under the URL.
- UI polish on the mirrors section: Set Primary, Remove, and the new Test
  action are compact icon buttons; add-mirror form moved into a dialog.
- "What's New" block prepended for v1.7.27-alpha.

21/21 update module tests pass. vue-tsc + vite build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-21 13:05:42 -04:00
co-authored by Claude Opus 4.7
parent 0d15ca588a
commit 9868991900
7 changed files with 280 additions and 44 deletions
+172 -42
View File
@@ -42,6 +42,12 @@
<div>
<h2 class="text-lg font-semibold text-white">{{ t('systemUpdate.updateAvailable') }}</h2>
<p class="text-sm text-white/60">Version {{ updateInfo.version }} &mdash; {{ updateInfo.release_date }}</p>
<p v-if="manifestMirrorLabel" class="text-xs text-white/40 mt-1 flex items-center gap-1.5">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M12 5l7 7-7 7" />
</svg>
<span>Served by <span class="text-white/70">{{ manifestMirrorLabel }}</span></span>
</p>
</div>
<span class="px-3 py-1 bg-orange-500/20 text-orange-400 text-xs font-medium rounded-full">{{ t('systemUpdate.new') }}</span>
</div>
@@ -179,57 +185,78 @@
<button
type="button"
class="text-xs px-3 py-1.5 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
@click="addingMirror = !addingMirror"
>{{ addingMirror ? 'Cancel' : '+ Add mirror' }}</button>
@click="openAddMirror"
>+ Add mirror</button>
</div>
<p class="text-sm text-white/60 mb-4">
Servers this node checks for updates. The primary is tried first; if it's slow or unreachable, the next one in the list is tried automatically. Downloads always come from the mirror that served the manifest — switching primary switches where files come from.
</p>
<ul v-if="mirrors.length" class="space-y-2 mb-3">
<li v-for="(m, i) in mirrors" :key="m.url" class="p-3 bg-white/5 rounded-lg flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<p class="text-sm font-medium text-white truncate">{{ m.label || `Mirror ${i + 1}` }}</p>
<span v-if="i === 0" class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300">PRIMARY</span>
<ul v-if="mirrors.length" class="space-y-2">
<li v-for="(m, i) in mirrors" :key="m.url" class="p-3 bg-white/5 rounded-lg">
<div class="flex items-start gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5 flex-wrap">
<p class="text-sm font-medium text-white truncate">{{ m.label || `Mirror ${i + 1}` }}</p>
<span v-if="i === 0" class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300">PRIMARY</span>
</div>
<p class="text-xs text-white/50 font-mono break-all">{{ m.url }}</p>
</div>
<div class="shrink-0 flex items-center gap-1">
<button
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="mirrorTests[m.url]?.testing"
title="Test reachability"
@click="testMirror(m.url)"
>
<svg v-if="mirrorTests[m.url]?.testing" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" stroke-opacity="0.25"></circle>
<path fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
<button
v-if="i !== 0"
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-yellow-300 hover:bg-white/10 transition-colors"
title="Make primary"
@click="setPrimaryMirror(m.url)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
</button>
<button
v-if="mirrors.length > 1"
type="button"
class="w-8 h-8 flex items-center justify-center rounded-md text-white/60 hover:text-red-300 hover:bg-red-400/10 transition-colors"
title="Remove mirror"
@click="removeMirror(m.url)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<p class="text-xs text-white/50 font-mono break-all">{{ m.url }}</p>
</div>
<div class="shrink-0 flex flex-col gap-1">
<button
v-if="i !== 0"
type="button"
class="text-xs px-2 py-1 rounded-md text-white/70 hover:bg-white/10 hover:text-white transition-colors"
title="Make this the primary mirror"
@click="setPrimaryMirror(m.url)"
>Set primary</button>
<button
v-if="mirrors.length > 1"
type="button"
class="text-xs px-2 py-1 rounded-md text-red-300 hover:bg-red-400/10 transition-colors"
@click="removeMirror(m.url)"
>Remove</button>
<div v-if="mirrorTests[m.url] && !mirrorTests[m.url]?.testing" class="mt-2 pt-2 border-t border-white/5 text-xs">
<span v-if="mirrorTests[m.url]?.reachable" class="inline-flex items-center gap-1.5 text-green-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
Reachable &middot; {{ mirrorTests[m.url]?.latency_ms }}ms
</span>
<span v-else class="inline-flex items-center gap-1.5 text-red-300">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="truncate">{{ mirrorTests[m.url]?.error || 'Unreachable' }}</span>
</span>
</div>
</li>
</ul>
<form v-if="addingMirror" class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-3" @submit.prevent="submitMirror">
<input
v-model="mirrorDraft.url"
type="text"
placeholder="https://host/.../manifest.json"
class="sm:col-span-2 px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
<input
v-model="mirrorDraft.label"
type="text"
placeholder="Label (optional)"
class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
<button
type="submit"
class="sm:col-span-3 min-h-[40px] glass-button rounded-lg text-sm font-medium disabled:opacity-60"
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
>{{ mirrorSaving ? 'Adding' : 'Add mirror' }}</button>
</form>
</div>
<!-- Actions row -->
@@ -317,6 +344,51 @@
</Transition>
</Teleport>
<!-- Add-mirror modal -->
<Transition name="fade">
<div v-if="addingMirror" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelAddMirror">
<div class="glass-card p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-semibold text-white mb-1">Add update mirror</h3>
<p class="text-sm text-white/60 mb-5">
The URL should point directly at a <span class="font-mono text-white/80">manifest.json</span> served by a Gitea mirror or equivalent. It's added to the end of the list; use "Make primary" to change order.
</p>
<form class="space-y-3" @submit.prevent="submitMirror">
<div>
<label class="block text-xs text-white/60 mb-1">Manifest URL</label>
<input
v-model="mirrorDraft.url"
type="text"
autofocus
placeholder="https://host/.../manifest.json"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none font-mono"
/>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Label (optional)</label>
<input
v-model="mirrorDraft.label"
type="text"
placeholder="Home VPS"
class="w-full px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none"
/>
</div>
<div class="flex gap-3 justify-end pt-2">
<button
type="button"
@click="cancelAddMirror"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
>{{ t('common.cancel') }}</button>
<button
type="submit"
class="glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="mirrorSaving || !mirrorDraft.url.trim()"
>{{ mirrorSaving ? 'Adding…' : 'Add mirror' }}</button>
</div>
</form>
</div>
</div>
</Transition>
<!-- Confirmation modal -->
<Transition name="fade">
<div v-if="confirmAction" class="fixed inset-0 z-50 flex items-center justify-center bg-black/10 backdrop-blur-md" @click.self="cancelConfirm">
@@ -416,6 +488,60 @@ const addingMirror = ref(false)
const mirrorSaving = ref(false)
const mirrorDraft = reactive({ url: '', label: '' })
// URL of the mirror that served the currently-available-update manifest.
// Backend reports it in update.status and update.check responses; the UI
// resolves it to a friendly label by matching against the mirrors list.
const manifestMirror = ref<string | null>(null)
const manifestMirrorLabel = computed(() => {
const url = manifestMirror.value
if (!url) return null
const match = mirrors.value.find(m => m.url === url)
if (match && match.label) return match.label
try {
const u = new URL(url)
return u.host
} catch {
return url
}
})
// Per-mirror test state. Populated by testMirror(); each entry is either
// { testing: true } while in flight or the backend response shape on
// completion. Rendered inline under each mirror row.
interface MirrorTestState {
testing?: boolean
reachable?: boolean
latency_ms?: number
http_status?: number | null
error?: string | null
}
const mirrorTests = ref<Record<string, MirrorTestState>>({})
async function testMirror(url: string) {
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: true } }
try {
const res = await rpcClient.call<{
reachable: boolean
latency_ms: number
http_status: number | null
error: string | null
}>({ method: 'update.test-mirror', params: { url } })
mirrorTests.value = { ...mirrorTests.value, [url]: { ...res, testing: false } }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
mirrorTests.value = { ...mirrorTests.value, [url]: { testing: false, reachable: false, error: msg } }
}
}
function openAddMirror() {
mirrorDraft.url = ''
mirrorDraft.label = ''
addingMirror.value = true
}
function cancelAddMirror() {
addingMirror.value = false
}
async function loadMirrors() {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({ method: 'update.list-mirrors' })
@@ -621,11 +747,13 @@ async function loadStatus() {
update_available: boolean
update_in_progress: boolean
rollback_available: boolean
manifest_mirror: string | null
}>({ method: 'update.status' })
currentVersion.value = res.current_version
lastCheck.value = res.last_check
updateInProgress.value = res.update_in_progress
rollbackAvailable.value = res.rollback_available
manifestMirror.value = res.manifest_mirror ?? null
if (res.update_in_progress) {
downloaded.value = true
@@ -645,11 +773,13 @@ async function checkForUpdates() {
update_available: boolean
update: UpdateDetail | null
update_method?: string
manifest_mirror?: string | null
}>({ method: 'update.check' })
currentVersion.value = res.current_version
lastCheck.value = res.last_check
updateInfo.value = res.update
updateMethod.value = res.update_method === 'git' ? 'git' : 'manifest'
manifestMirror.value = res.manifest_mirror ?? null
if (!res.update_available) {
showStatus(t('systemUpdate.upToDateMessage'))
}
@@ -180,6 +180,18 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.27-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.27-alpha</span>
<span class="text-xs text-white/40">Apr 21, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>The Update page now shows which mirror delivered your update a small "Served by" line under the new version tells you whether Server 1, Server 2, or a custom mirror was the one your node actually reached. Great for spot-checking that mirror fallback is doing its job.</p>
<p>Every mirror row has a new lightning-bolt button that pings the mirror and shows whether it's reachable, plus the round-trip latency in milliseconds. No more guessing if a mirror you just added is responding.</p>
<p>The Update mirrors section got a visual refresh: Set Primary, Remove, and the new Test action are compact icon buttons instead of crowded text, and adding a mirror now happens in a dedicated dialog that matches the rest of the UI.</p>
</div>
</div>
<!-- v1.7.26-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">