feat(supply-depot): add custom launch URLs for apps

Let users override an app's "Open" link with a reverse-proxy or
local-DNS address (e.g. https://jellyfin.myhomelab.net). Falls back to
the default host+port when unset. Metadata-only — no container changes.
This commit is contained in:
jakeaturner 2026-06-08 00:56:41 +00:00
parent ca5ec1767f
commit 026ab6df8f
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
14 changed files with 282 additions and 8 deletions

View File

@ -20,6 +20,8 @@ import {
updateCustomAppValidator,
updateServiceValidator,
setServiceAutoUpdateValidator,
setServiceCustomUrlValidator,
normalizeCustomUrl,
} from '#validators/system'
import {
DEFAULT_CPUS,
@ -447,6 +449,36 @@ export default class SystemController {
return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` })
}
/** Set or clear an app's custom launch URL (works for curated and custom apps). Purely a
* metadata change no container is touched. An empty/invalid value clears the override, after
* which the default host + port link is used again. */
async setServiceCustomUrl({ request, response }: HttpContext) {
const payload = await request.validateUsing(setServiceCustomUrlValidator)
const service = await Service.query().where('service_name', payload.service_name).first()
if (!service) {
return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` })
}
// Hidden dependency services (e.g. Qdrant) aren't user-launchable, so they have no link to set.
if (service.is_dependency_service) {
return response.status(403).send({ success: false, message: 'This service cannot be configured.' })
}
// Reject a non-empty value that isn't a valid http(s) URL; an empty value clears the override.
const normalized = normalizeCustomUrl(payload.custom_url)
if (payload.custom_url && payload.custom_url.trim() && !normalized) {
return response.status(422).send({
success: false,
message: 'Custom URL must be a valid http(s) address (e.g. https://jellyfin.myhomelab.net).',
})
}
service.custom_url = normalized
await service.save()
return response.send({ success: true, custom_url: service.custom_url })
}
/** Re-pull a custom app's image and recreate its container in place (preserving volumes). */
async updateCustomApp_pullLatest({ request, response }: HttpContext) {
const payload = await request.validateUsing(installServiceValidator)

View File

@ -59,6 +59,12 @@ export default class Service extends BaseModel {
@column()
declare ui_location: string | null
// User-set override for the launch ("Open") link (e.g. a reverse-proxy/local-DNS host like
// https://jellyfin.myhomelab.net). When null, the default host + port link derived from
// ui_location is used. Only affects user-facing links — never internal service-to-service URLs.
@column()
declare custom_url: string | null
@column()
declare metadata: string | null

View File

@ -302,6 +302,7 @@ export class SystemService {
'installed',
'installation_status',
'ui_location',
'custom_url',
'friendly_name',
'description',
'icon',
@ -338,6 +339,7 @@ export class SystemService {
installation_status: service.installation_status,
status: status ? status.status : 'unknown',
ui_location: service.ui_location || '',
custom_url: service.custom_url,
powered_by: service.powered_by,
display_order: service.display_order,
container_image: service.container_image,

View File

@ -95,6 +95,34 @@ export const customAppValidator = vine.compile(
})
)
// Set or clear an app's custom launch URL. A null/empty value clears the override; a non-empty
// value is normalized + validated to a http(s) URL by normalizeCustomUrl in the controller.
export const setServiceCustomUrlValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),
custom_url: vine.string().trim().nullable(),
})
)
/**
* Normalize a user-supplied custom app URL (backend twin of the inertia helper in
* lib/navigation.ts). Accepts a bare host or a full URL; prepends http:// when no scheme is
* present. Returns the normalized href, or null when empty (clears the override) or not a valid
* http(s) URL. Restricting to http/https blocks javascript:/data: from ever being stored.
*/
export function normalizeCustomUrl(input: string | null | undefined): string | null {
const trimmed = (input ?? '').trim()
if (!trimmed) return null
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
try {
const url = new URL(withScheme)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
return url.href
} catch {
return null
}
}
export const deleteCustomAppValidator = vine.compile(
vine.object({
service_name: vine.string().trim(),

View File

@ -0,0 +1,22 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// User-set override for an app's launch ("Open") link, used when the instance sits behind a
// reverse proxy or local DNS (e.g. https://jellyfin.myhomelab.net). When null, the default
// host + port link (derived from ui_location) is used. Stored separately from ui_location so
// the default is always recoverable, and deliberately NOT synced by the service seeder so a
// curated app's override survives reseeds/upgrades.
table.string('custom_url').nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('custom_url')
})
}
}

View File

@ -14,6 +14,7 @@ type ServiceSeedRecord = Omit<
| 'update_checked_at'
| 'metadata'
| 'is_user_modified'
| 'custom_url'
| 'auto_update_enabled'
| 'available_update_first_seen_at'
| 'auto_update_consecutive_failures'

View File

@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import StyledModal from './StyledModal'
import StyledButton from './StyledButton'
import Input from './inputs/Input'
import { getServiceLink, normalizeCustomUrl } from '~/lib/navigation'
import { ServiceSlim } from '../../types/services'
import api from '~/lib/api'
interface AppUrlModalProps {
open: boolean
/** The app whose launch URL is being configured. Null while closed. */
service: ServiceSlim | null
onClose: () => void
/** Called after a successful save/clear so the parent can refresh the link. */
onSaved: () => void
showError: (msg: string) => void
}
/**
* Set or clear an app's custom launch URL used when the instance sits behind a reverse proxy or
* local DNS (e.g. https://jellyfin.myhomelab.net). Leaving the field empty clears the override and
* reverts to the default host + port link. Works for both curated and custom apps.
*/
export default function AppUrlModal({ open, service, onClose, onSaved, showError }: AppUrlModalProps) {
const [value, setValue] = useState('')
const [submitting, setSubmitting] = useState(false)
// Prefill from the app's stored override each time the modal opens for a service.
useEffect(() => {
if (open && service) setValue(service.custom_url ?? '')
}, [open, service])
const trimmed = value.trim()
const normalized = normalizeCustomUrl(value)
const isInvalid = trimmed.length > 0 && !normalized
// What clicking "Open" will actually resolve to once saved.
const previewLink = service ? getServiceLink(service.ui_location || '', value) : ''
const usingDefault = !normalized
async function handleSave() {
if (!service || isInvalid) return
setSubmitting(true)
// Empty clears the override; the backend re-normalizes/validates the value too.
const result = await api.setServiceCustomUrl(service.service_name, trimmed ? trimmed : null)
setSubmitting(false)
if (!result?.success) {
showError('Failed to save custom URL.')
return
}
onSaved()
}
const appName = service?.friendly_name || service?.service_name || 'this app'
return (
<StyledModal
title="Set Custom URL"
open={open}
onCancel={onClose}
onClose={onClose}
cancelText="Cancel"
onConfirm={handleSave}
confirmVariant="primary"
confirmText="Save"
confirmIcon="IconCheck"
confirmLoading={submitting}
confirmDisabled={isInvalid}
>
<div className="space-y-4 text-sm">
<p className="text-text-muted">
Set where <span className="font-medium text-text-primary">{appName}</span> opens from useful
if you reach it through a reverse proxy or local DNS. Leave this empty to use the default
address ({service?.ui_location ? `host + port ${service.ui_location}` : 'host + port'}).
</p>
<div>
<div className="flex items-end gap-2">
<Input
name="customUrl"
label="Custom URL"
placeholder="http://jellyfin.myhomelab.net"
value={value}
onChange={(e) => setValue(e.target.value)}
error={isInvalid}
className="flex-1 min-w-0"
/>
{value.length > 0 && (
<StyledButton size="sm" variant="ghost" icon="IconX" onClick={() => setValue('')} className="mb-1.5">
Clear
</StyledButton>
)}
</div>
{isInvalid ? (
<p className="mt-1.5 text-xs text-red-500">
Enter a valid http(s) address (e.g. https://jellyfin.myhomelab.net). A bare host like
"jellyfin.lan" becomes http://jellyfin.lan.
</p>
) : (
<>
<p className="mt-1.5 text-xs text-text-muted">
No scheme? We'll default to <span className="font-mono">http://</span>.</p>
<p className="mt-1.5 text-xs text-text-muted">
Opens as:{' '}
<span className="font-mono break-all text-text-primary">{previewLink}</span>
{usingDefault ? ' (default)' : ''}
</p>
</>
)}
</div>
</div>
</StyledModal>
)
}

View File

@ -1049,6 +1049,16 @@ class API {
})()
}
async setServiceCustomUrl(service_name: string, custom_url: string | null) {
return catchInternal(async () => {
const response = await this.client.put<{ success: boolean; custom_url: string | null }>(
'/system/services/custom-url',
{ service_name, custom_url }
)
return response.data
})()
}
async deleteCustomApp(service_name: string, remove_image = false) {
return catchInternal(async () => {
const response = await this.client.delete<{ success: boolean; message: string }>(

View File

@ -1,6 +1,32 @@
/**
* Normalize a user-supplied custom app URL. Accepts a bare host ("jellyfin.lan",
* "10.0.0.5:8096") or a full URL; when no scheme is present, http:// is prepended
* (LAN resources are usually plain HTTP). Returns the normalized href, or null when
* the input is empty (clears the override) or not a valid http(s) URL. Restricting to
* http/https is the XSS guard javascript:/data: URLs never make it into an href.
*/
export function normalizeCustomUrl(input: string | null | undefined): string | null {
const trimmed = (input ?? '').trim();
if (!trimmed) return null;
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
try {
const url = new URL(withScheme);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.href;
} catch {
return null;
}
}
export function getServiceLink(ui_location: string, customUrl?: string | null): string {
// A user-set custom URL (reverse proxy / local DNS) overrides the computed default. Only
// accepted when it normalizes to a valid http(s) URL — otherwise fall through to the default.
const normalizedCustom = normalizeCustomUrl(customUrl);
if (normalizedCustom) {
return normalizedCustom;
}
export function getServiceLink(ui_location: string): string {
// "https:8480" / "http:8480" — an explicit scheme + port served on the current host. Checked
// before the URL parse below because new URL("https:8480") would mis-parse 8480 as the host.
const schemePort = ui_location.match(/^(https?):(\d+)$/);

View File

@ -101,12 +101,15 @@ export default function Home(props: {
// Add installed services (non-dependency services only)
props.system.services
.filter((service) => service.installed && service.ui_location)
.filter((service) => service.installed && (service.ui_location || service.custom_url))
.forEach((service) => {
items.push({
// Inject custom AI Assistant name if this is the chat service
label: service.service_name === SERVICE_NAMES.OLLAMA && aiAssistantName ? aiAssistantName : (service.friendly_name || service.service_name),
to: service.ui_location ? getServiceLink(service.ui_location) : '#',
to:
service.ui_location || service.custom_url
? getServiceLink(service.ui_location || '', service.custom_url)
: '#',
target: '_blank',
description:
service.description ||

View File

@ -253,7 +253,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
<StyledButton
icon={'IconExternalLink'}
onClick={() => {
window.open(getServiceLink(record.ui_location || 'unknown'), '_blank')
window.open(getServiceLink(record.ui_location || 'unknown', record.custom_url), '_blank')
}}
>
Open
@ -374,7 +374,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
title: 'Location',
render: (record) => (
<a
href={getServiceLink(record.ui_location || 'unknown')}
href={getServiceLink(record.ui_location || 'unknown', record.custom_url)}
target="_blank"
rel="noopener noreferrer"
className="text-desert-green hover:underline font-semibold"

View File

@ -17,6 +17,7 @@ import {
IconRefresh,
IconSearch,
IconTrash,
IconWorld,
} from '@tabler/icons-react'
import AppLayout from '~/layouts/AppLayout'
import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon'
@ -26,6 +27,7 @@ import InstallActivityFeed from '~/components/InstallActivityFeed'
import LoadingSpinner from '~/components/LoadingSpinner'
import Alert from '~/components/Alert'
import CustomAppModal, { CustomAppInitial } from '~/components/CustomAppModal'
import AppUrlModal from '~/components/AppUrlModal'
import ServiceLogsModal from '~/components/ServiceLogsModal'
import ServiceStatsModal from '~/components/ServiceStatsModal'
import StyledSectionHeader from '~/components/StyledSectionHeader'
@ -104,6 +106,8 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [customAppOpen, setCustomAppOpen] = useState(false)
const [editApp, setEditApp] = useState<CustomAppInitial | null>(null)
// App whose custom launch URL is being configured (null while the modal is closed).
const [urlApp, setUrlApp] = useState<ServiceSlim | null>(null)
const [removeImage, setRemoveImage] = useState(false)
// Optimistic per-app auto-update toggle state, keyed by service_name. Lets the
// toggle reflect instantly without a full page reload (props come from Inertia).
@ -305,6 +309,17 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
setTimeout(() => window.location.reload(), 1500)
}
function handleSetUrl(service: ServiceSlim) {
setOpenDropdown(null)
setUrlApp(service)
}
function handleUrlSaved() {
setUrlApp(null)
// Reload so the new link flows through to the card, /home, and settings.
window.location.reload()
}
// ── Install modal helpers ─────────────────────────────────────────────────
const hasPreflightWarnings =
(preflight?.portConflicts.length ?? 0) > 0 || (preflight?.resourceWarnings.length ?? 0) > 0
@ -438,6 +453,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
onLogs={() => setModal({ type: 'logs', service })}
onStats={() => setModal({ type: 'stats', service })}
onEdit={() => handleEdit(service)}
onSetUrl={() => handleSetUrl(service)}
onUpdate={() => handleUpdate(service)}
onUpdateVersion={() => setModal({ type: 'update', service })}
autoUpdateEnabled={
@ -471,6 +487,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
onLogs={() => setModal({ type: 'logs', service })}
onStats={() => setModal({ type: 'stats', service })}
onEdit={() => handleEdit(service)}
onSetUrl={() => handleSetUrl(service)}
onUpdate={() => handleUpdate(service)}
onUpdateVersion={() => setModal({ type: 'update', service })}
/>
@ -695,6 +712,15 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
onCreated={handleEdited}
showError={showError}
/>
{/* Custom launch URL modal */}
<AppUrlModal
open={!!urlApp}
service={urlApp}
onClose={() => setUrlApp(null)}
onSaved={handleUrlSaved}
showError={showError}
/>
</AppLayout>
)
}
@ -715,6 +741,7 @@ interface AppCardProps {
onLogs: () => void
onStats: () => void
onEdit: () => void
onSetUrl: () => void
onUpdate: () => void
onUpdateVersion: () => void
// Installed-only: per-app auto-update preference + toggle handler.
@ -738,6 +765,7 @@ function AppCard({
onLogs,
onStats,
onEdit,
onSetUrl,
onUpdate,
onUpdateVersion,
autoUpdateEnabled,
@ -878,10 +906,10 @@ function AppCard({
{service.installed ? (
<>
{/* Open button */}
{service.ui_location && (
{/* Open button — shown when the app has a default location or a user-set custom URL */}
{(service.ui_location || service.custom_url) && (
<a
href={getServiceLink(service.ui_location)}
href={getServiceLink(service.ui_location, service.custom_url)}
target="_blank"
rel="noopener noreferrer"
className="flex-1"
@ -922,6 +950,7 @@ function AppCard({
<DropdownItem icon={<IconFileText className="h-4 w-4" />} label="Logs" onClick={onLogs} />
<DropdownItem icon={<IconChartBar className="h-4 w-4" />} label="Stats" onClick={onStats} />
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
<DropdownItem icon={<IconWorld className="h-4 w-4" />} label="Set custom URL" onClick={onSetUrl} />
{!service.is_custom && onToggleAutoUpdate ? (
autoUpdateMasterEnabled ? (
<DropdownItem

View File

@ -178,6 +178,7 @@ router
router.post('/services/custom/update', [SystemController, 'updateCustomApp_pullLatest'])
router.delete('/services/custom', [SystemController, 'deleteCustomApp'])
router.get('/services/custom/:name', [SystemController, 'getCustomApp'])
router.put('/services/custom-url', [SystemController, 'setServiceCustomUrl'])
router.get('/services/:name/logs', [SystemController, 'getServiceLogs'])
router.get('/services/:name/stats', [SystemController, 'getServiceStats'])
router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions'])

View File

@ -7,6 +7,7 @@ export type ServiceSlim = Pick<
| 'installed'
| 'installation_status'
| 'ui_location'
| 'custom_url'
| 'friendly_name'
| 'description'
| 'icon'