release(v1.7.29-alpha): VPS as default app registry + settings UI
- New Settings → App registries page (/dashboard/settings/registries)
that mirrors the update-mirrors experience: list of configured
registries, test reachability, set primary, add/remove. New
registry.set-primary RPC; existing registry.{list,add,remove,test}
reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
URL before attempting it. Before this, installs always hit whichever
registry the image was hardcoded to, so changing the primary didn't
actually affect where images came from. On failure, the existing
fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
(matches the screensaver composition). Extracted the logo-wrapper
pattern inline.
7/7 registry tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:07 -04:00
|
|
|
<template>
|
|
|
|
|
<div class="pb-6">
|
|
|
|
|
<div class="mb-6">
|
|
|
|
|
<h1 class="text-3xl font-bold text-white mb-2">App registries</h1>
|
|
|
|
|
<p class="text-white/70">
|
|
|
|
|
Container registries this node pulls app images from. The primary is tried first; if it's
|
|
|
|
|
slow or unreachable, the next one in the list is tried automatically.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Status message -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="statusMessage"
|
|
|
|
|
class="mb-4 p-3 rounded-lg text-sm"
|
|
|
|
|
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
|
|
|
|
|
>
|
|
|
|
|
{{ statusMessage }}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Registry list -->
|
|
|
|
|
<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">Registries</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="openAddRegistry"
|
|
|
|
|
>+ Add registry</button>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="text-sm text-white/60 mb-4">
|
|
|
|
|
Registries are tried in priority order on every app install. Changing the primary takes
|
|
|
|
|
effect on the next install — existing containers keep running on whatever image they
|
|
|
|
|
already pulled.
|
|
|
|
|
</p>
|
|
|
|
|
<ul v-if="registries.length" class="space-y-2">
|
|
|
|
|
<li
|
|
|
|
|
v-for="r in sortedRegistries"
|
|
|
|
|
:key="r.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">{{ r.name || r.url }}</p>
|
|
|
|
|
<span
|
|
|
|
|
v-if="r.priority === 0"
|
|
|
|
|
class="text-[10px] font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-300"
|
|
|
|
|
>PRIMARY</span>
|
|
|
|
|
<span
|
|
|
|
|
v-if="!r.tls_verify"
|
|
|
|
|
class="text-[10px] font-mono px-2 py-0.5 rounded bg-amber-500/20 text-amber-300"
|
|
|
|
|
title="TLS verification disabled — HTTP or self-signed registry"
|
|
|
|
|
>HTTP</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="text-xs text-white/50 font-mono break-all">{{ r.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="registryTests[r.url]?.testing"
|
|
|
|
|
title="Test reachability"
|
|
|
|
|
@click="testRegistry(r)"
|
|
|
|
|
>
|
|
|
|
|
<svg v-if="registryTests[r.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="r.priority !== 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="setPrimary(r.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="registries.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 registry"
|
|
|
|
|
@click="removeRegistry(r.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>
|
|
|
|
|
</div>
|
|
|
|
|
<div
|
|
|
|
|
v-if="registryTests[r.url] && !registryTests[r.url]?.testing"
|
|
|
|
|
class="mt-2 pt-2 border-t border-white/5 text-xs"
|
|
|
|
|
>
|
|
|
|
|
<span v-if="registryTests[r.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 (HTTP {{ registryTests[r.url]?.status }})
|
|
|
|
|
</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">{{ registryTests[r.url]?.error || 'Unreachable' }}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</li>
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Back link -->
|
|
|
|
|
<RouterLink
|
|
|
|
|
to="/dashboard/settings"
|
|
|
|
|
class="glass-button rounded-lg px-5 py-2 text-sm font-medium inline-flex items-center gap-2"
|
|
|
|
|
>
|
|
|
|
|
<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="M15 19l-7-7 7-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
Back to Settings
|
|
|
|
|
</RouterLink>
|
|
|
|
|
|
|
|
|
|
<!-- Add-registry modal -->
|
|
|
|
|
<Teleport to="body">
|
|
|
|
|
<Transition name="fade">
|
|
|
|
|
<div
|
|
|
|
|
v-if="addingRegistry"
|
2026-06-11 00:24:40 -04:00
|
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md"
|
release(v1.7.29-alpha): VPS as default app registry + settings UI
- New Settings → App registries page (/dashboard/settings/registries)
that mirrors the update-mirrors experience: list of configured
registries, test reachability, set primary, add/remove. New
registry.set-primary RPC; existing registry.{list,add,remove,test}
reused.
- Default RegistryConfig flipped: VPS (23.182.128.160:3000/lfg2025) is
now Server 1 (primary), tx1138 is Server 2 (fallback).
- Install pipeline now rewrites the first pull to the primary registry
URL before attempting it. Before this, installs always hit whichever
registry the image was hardcoded to, so changing the primary didn't
actually affect where images came from. On failure, the existing
fallback walk skips the primary (already tried) and walks the rest.
- App catalog proxy UPSTREAMS order flipped so the catalog follows the
same VPS-first rule.
- Reboot overlay: animated "a" logo now sits in the center of the ring
(matches the screensaver composition). Extracted the logo-wrapper
pattern inline.
7/7 registry tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:07 -04:00
|
|
|
@click.self="cancelAddRegistry"
|
|
|
|
|
>
|
|
|
|
|
<div class="glass-card p-6 max-w-md w-full mx-4">
|
|
|
|
|
<h3 class="text-lg font-semibold text-white mb-1">Add app registry</h3>
|
|
|
|
|
<p class="text-sm text-white/60 mb-5">
|
|
|
|
|
The URL should be of the form <span class="font-mono text-white/80">host[:port]/namespace</span>
|
|
|
|
|
— for example <span class="font-mono text-white/80">ghcr.io/myorg</span> or
|
|
|
|
|
<span class="font-mono text-white/80">192.168.1.50:3000/apps</span>. Registries are
|
|
|
|
|
added to the end of the list; use "Make primary" to reorder.
|
|
|
|
|
</p>
|
|
|
|
|
<form class="space-y-3" @submit.prevent="submitRegistry">
|
|
|
|
|
<div>
|
|
|
|
|
<label class="block text-xs text-white/60 mb-1">Name</label>
|
|
|
|
|
<input
|
|
|
|
|
v-model="registryDraft.name"
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="My private registry"
|
|
|
|
|
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>
|
|
|
|
|
<label class="block text-xs text-white/60 mb-1">Registry URL</label>
|
|
|
|
|
<input
|
|
|
|
|
v-model="registryDraft.url"
|
|
|
|
|
type="text"
|
|
|
|
|
autofocus
|
|
|
|
|
placeholder="host:port/namespace"
|
|
|
|
|
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>
|
|
|
|
|
<label class="flex items-center gap-2 cursor-pointer text-sm text-white/80">
|
|
|
|
|
<input
|
|
|
|
|
v-model="registryDraft.tls_verify"
|
|
|
|
|
type="checkbox"
|
|
|
|
|
class="accent-orange-400"
|
|
|
|
|
/>
|
|
|
|
|
Verify TLS certificate (uncheck for HTTP or self-signed)
|
|
|
|
|
</label>
|
|
|
|
|
<div class="flex gap-3 justify-end pt-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
@click="cancelAddRegistry"
|
|
|
|
|
class="glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
|
|
|
|
>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="registrySaving || !registryDraft.url.trim()"
|
|
|
|
|
>{{ registrySaving ? 'Adding…' : 'Add registry' }}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Transition>
|
|
|
|
|
</Teleport>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { ref, computed, onMounted, reactive } from 'vue'
|
|
|
|
|
import { RouterLink } from 'vue-router'
|
|
|
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
|
|
|
|
|
|
|
|
interface Registry {
|
|
|
|
|
url: string
|
|
|
|
|
name: string
|
|
|
|
|
tls_verify: boolean
|
|
|
|
|
enabled: boolean
|
|
|
|
|
priority: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RegistryTestState {
|
|
|
|
|
testing?: boolean
|
|
|
|
|
reachable?: boolean
|
|
|
|
|
status?: number | null
|
|
|
|
|
error?: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const registries = ref<Registry[]>([])
|
|
|
|
|
const sortedRegistries = computed(() =>
|
|
|
|
|
[...registries.value].sort((a, b) => a.priority - b.priority)
|
|
|
|
|
)
|
|
|
|
|
const registryTests = ref<Record<string, RegistryTestState>>({})
|
|
|
|
|
const statusMessage = ref('')
|
|
|
|
|
const statusIsError = ref(false)
|
|
|
|
|
|
|
|
|
|
const addingRegistry = ref(false)
|
|
|
|
|
const registrySaving = ref(false)
|
|
|
|
|
const registryDraft = reactive({ url: '', name: '', tls_verify: true })
|
|
|
|
|
|
|
|
|
|
function showStatus(msg: string, isError = false) {
|
|
|
|
|
statusMessage.value = msg
|
|
|
|
|
statusIsError.value = isError
|
|
|
|
|
setTimeout(() => { statusMessage.value = '' }, 8000)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadRegistries() {
|
|
|
|
|
try {
|
|
|
|
|
const res = await rpcClient.call<{ registries: Registry[] }>({ method: 'registry.list' })
|
|
|
|
|
registries.value = res.registries
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (import.meta.env.DEV) console.warn('registry.list failed', e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openAddRegistry() {
|
|
|
|
|
registryDraft.url = ''
|
|
|
|
|
registryDraft.name = ''
|
|
|
|
|
registryDraft.tls_verify = true
|
|
|
|
|
addingRegistry.value = true
|
|
|
|
|
}
|
|
|
|
|
function cancelAddRegistry() {
|
|
|
|
|
addingRegistry.value = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitRegistry() {
|
|
|
|
|
const url = registryDraft.url.trim()
|
|
|
|
|
if (!url) return
|
|
|
|
|
registrySaving.value = true
|
|
|
|
|
try {
|
|
|
|
|
const res = await rpcClient.call<{ registries: Registry[] }>({
|
|
|
|
|
method: 'registry.add',
|
|
|
|
|
params: {
|
|
|
|
|
url,
|
|
|
|
|
name: registryDraft.name.trim() || url,
|
|
|
|
|
tls_verify: registryDraft.tls_verify,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
registries.value = res.registries
|
|
|
|
|
addingRegistry.value = false
|
|
|
|
|
showStatus('Registry added.')
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
|
|
|
showStatus(`Add registry failed: ${msg}`, true)
|
|
|
|
|
} finally {
|
|
|
|
|
registrySaving.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function removeRegistry(url: string) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await rpcClient.call<{ registries: Registry[] }>({
|
|
|
|
|
method: 'registry.remove',
|
|
|
|
|
params: { url },
|
|
|
|
|
})
|
|
|
|
|
registries.value = res.registries
|
|
|
|
|
showStatus('Registry removed.')
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
|
|
|
showStatus(`Remove failed: ${msg}`, true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function setPrimary(url: string) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await rpcClient.call<{ registries: Registry[] }>({
|
|
|
|
|
method: 'registry.set-primary',
|
|
|
|
|
params: { url },
|
|
|
|
|
})
|
|
|
|
|
registries.value = res.registries
|
|
|
|
|
showStatus('Primary registry updated. Next install will try it first.')
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
|
|
|
showStatus(`Set primary failed: ${msg}`, true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function testRegistry(r: Registry) {
|
|
|
|
|
registryTests.value = { ...registryTests.value, [r.url]: { testing: true } }
|
|
|
|
|
try {
|
|
|
|
|
const res = await rpcClient.call<{
|
|
|
|
|
url: string
|
|
|
|
|
reachable: boolean
|
|
|
|
|
status: number | null
|
|
|
|
|
error?: string | null
|
|
|
|
|
}>({ method: 'registry.test', params: { url: r.url, tls_verify: r.tls_verify } })
|
|
|
|
|
registryTests.value = {
|
|
|
|
|
...registryTests.value,
|
|
|
|
|
[r.url]: {
|
|
|
|
|
testing: false,
|
|
|
|
|
reachable: res.reachable,
|
|
|
|
|
status: res.status,
|
|
|
|
|
error: res.error ?? null,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
|
|
|
registryTests.value = {
|
|
|
|
|
...registryTests.value,
|
|
|
|
|
[r.url]: { testing: false, reachable: false, error: msg },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => { void loadRegistries() })
|
|
|
|
|
</script>
|