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:
parent
ca5ec1767f
commit
026ab6df8f
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }>(
|
||||
|
|
|
|||
|
|
@ -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+)$/);
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export type ServiceSlim = Pick<
|
|||
| 'installed'
|
||||
| 'installation_status'
|
||||
| 'ui_location'
|
||||
| 'custom_url'
|
||||
| 'friendly_name'
|
||||
| 'description'
|
||||
| 'icon'
|
||||
|
|
|
|||
Loading…
Reference in New Issue