release(v1.7.26-alpha): mirror list + origin-relative download URLs

Adds a multi-mirror manifest fetch. `check_for_updates` walks a
configurable list (data_dir/update-mirrors.json) in priority order
and falls through to the next mirror on any HTTP / parse / timeout
failure. Two defaults bake in: Server 1 (git.tx1138.com) and Server 2
(23.182.128.160:3000).

Critical fix: after parsing a manifest, rewrite every component's
`download_url` so its origin matches the manifest URL we fetched.
Before this, the manifest hard-coded absolute URLs pointing at one
specific server — so even when a node fetched the manifest from a
faster mirror, the actual 200MB download went back to the slow
original. Now the faster mirror wins end-to-end.

New RPCs: update.list-mirrors, update.add-mirror, update.remove-mirror,
update.set-primary-mirror. New UI section on the System Update page
for operator management. 5 new unit tests for origin parsing and
manifest rewriting (21/21 green).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-21 10:09:28 -04:00
parent 1c1416cc1a
commit 0d15ca588a
7 changed files with 482 additions and 33 deletions

View File

@@ -172,6 +172,66 @@
</div>
</div>
<!-- Mirrors -->
<div class="glass-card p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-2">
<h2 class="text-lg font-semibold text-white">Update mirrors</h2>
<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>
</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>
</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>
</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 -->
<div class="glass-card p-6">
<h2 class="text-lg font-semibold text-white mb-4">{{ t('systemUpdate.actions') }}</h2>
@@ -304,7 +364,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
@@ -346,6 +406,79 @@ const statusIsError = ref(false)
const downloadPercent = ref(0)
const downloadPercentFormatted = computed(() => downloadPercent.value.toFixed(2))
// Mirrors — servers this node tries for the manifest, in priority
// order. First entry is the primary. Add/remove/set-primary are wired
// to update.*-mirror RPCs; downloads automatically go to the mirror
// that served the manifest.
interface UpdateMirror { url: string; label: string }
const mirrors = ref<UpdateMirror[]>([])
const addingMirror = ref(false)
const mirrorSaving = ref(false)
const mirrorDraft = reactive({ url: '', label: '' })
async function loadMirrors() {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({ method: 'update.list-mirrors' })
mirrors.value = res.mirrors
} catch (e) {
if (import.meta.env.DEV) console.warn('update.list-mirrors failed', e)
}
}
async function submitMirror() {
const url = mirrorDraft.url.trim()
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
showStatus('Mirror URL must start with http:// or https://', true)
return
}
mirrorSaving.value = true
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
method: 'update.add-mirror',
params: { url, label: mirrorDraft.label.trim() },
})
mirrors.value = res.mirrors
mirrorDraft.url = ''
mirrorDraft.label = ''
addingMirror.value = false
showStatus('Mirror added.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Add mirror failed: ${msg}`, true)
} finally {
mirrorSaving.value = false
}
}
async function removeMirror(url: string) {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
method: 'update.remove-mirror',
params: { url },
})
mirrors.value = res.mirrors
showStatus('Mirror removed.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Remove failed: ${msg}`, true)
}
}
async function setPrimaryMirror(url: string) {
try {
const res = await rpcClient.call<{ mirrors: UpdateMirror[] }>({
method: 'update.set-primary-mirror',
params: { url },
})
mirrors.value = res.mirrors
showStatus('Primary mirror updated. Next update check will try it first.')
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
showStatus(`Set primary failed: ${msg}`, true)
}
}
// Poll the backend for the real bytes_downloaded / total_bytes so the
// progress bar tracks actual download state (and survives route
// changes). Returns true if a download is currently in progress.
@@ -685,7 +818,7 @@ async function setSchedule(value: ScheduleValue) {
}
onMounted(async () => {
await Promise.all([loadStatus(), loadSchedule(), checkForUpdates()])
await Promise.all([loadStatus(), loadSchedule(), loadMirrors(), checkForUpdates()])
// If a download was already running when the user navigated here
// (or refreshed), pick up the progress bar where it is and keep
// polling until the backend reports done. No RPC call to start the

View File

@@ -180,6 +180,19 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.26-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.26-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>Update downloads now have a mirror list. If the primary update server is slow or unreachable, your node automatically tries the next mirror and downloads the files from there no more waiting on a stalled server with no recourse.</p>
<p>A new 'Update mirrors' section on the System Update page lets you see the list, add your own mirror URL, reorder which is tried first (Set primary), or remove one. The primary is tagged with a green PRIMARY pill.</p>
<p>Downloads automatically follow the mirror that served the manifest. Previously every mirror served the same manifest, and the manifest's download URLs were hardcoded to a single server — so even picking a faster mirror couldn't speed up the actual download. Now the backend rewrites download URLs to match whichever mirror succeeded.</p>
<p>Ships with two defaults: Server 1 (tx1138) and Server 2 (VPS). Add the URL format <code>https://host/.../releases/manifest.json</code> for custom mirrors.</p>
</div>
</div>
<!-- v1.7.25-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">