feat(builder): highlight corresponding yaml for focused field (#761)

* feat: action link (#769)

* feat(builder): highlight corresponding yaml for focused field

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
bo0tzz
2026-09-02 15:49:42 -04:00
committed by GitHub
co-authored by Jason Rasmussen
parent 91114a8a11
commit 7462546471
7 changed files with 556 additions and 45 deletions
+90 -14
View File
@@ -2,16 +2,16 @@ import deepmerge from 'deepmerge';
import { stringify } from 'yaml';
import { ML_BACKENDS, TRANSCODE_BACKENDS } from './hwaccel';
import { IMAGES } from './images';
import { FOLDER_OVERRIDES, type ImmichConfig } from './config';
import type { ComposeFile, ComposeService } from './spec';
import { FIELDS, FOLDER_OVERRIDES, type ImmichConfig, type OutputContext } from './config';
import type { ComposeFile, ComposeService, FieldPaths, YamlPath } from './spec';
const rootlessHardening = ({ uid, gid }: ImmichConfig['rootless']): ComposeService => ({
export const rootlessHardening = ({ uid, gid }: ImmichConfig['rootless']): ComposeService => ({
user: `${uid ?? ''}:${gid ?? ''}`,
security_opt: ['no-new-privileges:true'],
cap_drop: ['NET_RAW'],
});
const ROOTLESS_VOLUMES = new Map<string, string[]>([
export const ROOTLESS_VOLUMES = new Map<string, string[]>([
['immich-machine-learning', ['./ml-model-cache:/cache', './ml-dotcache:/.cache', './ml-config:/.config']],
['redis', ['./redis:/data']],
]);
@@ -60,16 +60,18 @@ const buildServerEnvironment = ({ timezone, database, redis }: ImmichConfig) =>
return environment;
};
export const buildComposeSpec = (config: ImmichConfig, version: string): ComposeFile => {
const build = (config: ImmichConfig, version: string): { spec: ComposeFile; fields: FieldPaths } => {
const { rootless, network } = config;
const serverEnvironment = buildServerEnvironment(config);
const containerName = (name: string) => (config.containerNames ? name : '');
const externalNetwork = network.external ? network.name.trim() : '';
const libraryMounts = config.storage.externalLibraries
.filter(({ path }) => path.trim())
.map(({ path, readOnly }) => `${path.trim()}:${path.trim()}${readOnly ? ':ro' : ''}`);
const libraries = config.storage.externalLibraries
.map(({ path, readOnly }, index) => ({ path: path.trim(), readOnly, index }))
.filter(({ path }) => path);
const libraryMounts = libraries.map(({ path, readOnly }) => `${path}:${path}${readOnly ? ':ro' : ''}`);
const libraryIndices = libraries.map(({ index }) => index);
const rootlessVolumes = (name: string) => (rootless.enabled ? ROOTLESS_VOLUMES.get(name) : undefined);
@@ -85,6 +87,13 @@ export const buildComposeSpec = (config: ImmichConfig, version: string): Compose
};
}
const databaseVolume: YamlPath[] = config.database.external
? []
: [
['services', 'database', 'volumes', 0],
...(config.database.mount.type === 'volume' ? [['volumes', NamedVolume.PostgresData]] : []),
];
if (!config.database.external) {
backingServices.database = {
container_name: containerName('immich_postgres'),
@@ -105,11 +114,13 @@ export const buildComposeSpec = (config: ImmichConfig, version: string): Compose
};
}
const overrideMounts = config.storage.customFolders
? FOLDER_OVERRIDES.filter(({ key }) => config.storage.overrides[key].trim()).map(
({ key, subfolder }) => `${config.storage.overrides[key].trim()}:/data/${subfolder}`,
)
const overrides = config.storage.customFolders
? FOLDER_OVERRIDES.filter(({ key }) => config.storage.overrides[key].trim())
: [];
const overrideMounts = overrides.map(
({ key, subfolder }) => `${config.storage.overrides[key].trim()}:/data/${subfolder}`,
);
const overrideKeys = overrides.map(({ key }) => key);
const services: Record<string, ComposeService> = {
'immich-server': deepmerge<ComposeService>(
@@ -144,9 +155,19 @@ export const buildComposeSpec = (config: ImmichConfig, version: string): Compose
...backingServices,
};
const rootlessPaths: YamlPath[] = [];
const rootlessUserPaths: YamlPath[] = [];
if (rootless.enabled) {
for (const [name, service] of Object.entries(services)) {
services[name] = deepmerge(service, rootlessHardening(rootless));
const hardening = rootlessHardening(rootless);
services[name] = deepmerge(service, hardening);
rootlessUserPaths.push(['services', name, 'user']);
rootlessPaths.push(
...Object.keys(hardening).map((key) => ['services', name, key]),
...(rootlessVolumes(name) ? [['services', name, 'volumes']] : []),
);
}
}
@@ -163,8 +184,63 @@ export const buildComposeSpec = (config: ImmichConfig, version: string): Compose
const spec: ComposeFile = { name: 'immich', services, volumes, networks };
return pruneEmpty(spec);
const context: OutputContext = {
config,
server: ['services', 'immich-server'],
ml: ['services', 'immich-machine-learning'],
services: Object.keys(services),
env: (key) => {
const value = serverEnvironment[key];
return value === undefined || value === '' ? [] : [['services', 'immich-server', 'environment', key]];
},
bundledDatabase: !config.database.external,
databaseVolume,
network: externalNetwork
? [
['services', 'immich-server', 'networks'],
['networks', externalNetwork],
]
: [],
rootless: rootlessPaths,
rootlessUser: rootlessUserPaths,
overrides: overrideKeys.map((_, position) => ['services', 'immich-server', 'volumes', 1 + position]),
libraries: libraryIndices.map((_, position) => [
'services',
'immich-server',
'volumes',
1 + overrideMounts.length + position,
]),
fragment: (of) =>
of === 'transcoding'
? Object.keys(TRANSCODE_BACKENDS[config.hwaccel.transcoding].fragment).map((key) => [
'services',
'immich-server',
key,
])
: Object.keys(ML_BACKENDS[config.hwaccel.ml].fragment).map((key) => [
'services',
'immich-machine-learning',
key,
]),
};
const fields: FieldPaths = Object.fromEntries(
Object.entries(FIELDS).map(([id, field]) => [id, 'output' in field ? field.output(context) : []]),
);
for (const [position, key] of overrideKeys.entries()) {
fields[`override:${key}`] = [context.overrides[position]];
}
for (const [position, index] of libraryIndices.entries()) {
fields[`library:${index}`] = [context.libraries[position]];
}
return { spec: pruneEmpty(spec), fields };
};
export const buildComposeSpec = (config: ImmichConfig, version: string): ComposeFile => build(config, version).spec;
export const buildComposeFields = (config: ImmichConfig, version: string): FieldPaths => build(config, version).fields;
export const buildCompose = (config: ImmichConfig, version: string): string =>
stringify(buildComposeSpec(config, version), { lineWidth: 0, nullStr: '' });
+107 -11
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import type { YamlPath } from './spec';
import { ML_BACKENDS, TRANSCODE_BACKENDS, type MlAccel, type TranscodeAccel } from './hwaccel';
const databaseMountSchema = z.discriminatedUnion('type', [
@@ -82,21 +83,116 @@ const immichConfigSchema = z.object({
export type ImmichConfig = z.infer<typeof immichConfigSchema>;
const ADVANCED_RESETS: ((config: ImmichConfig) => void)[] = [
(config) => (config.port = DEFAULT_CONFIG.port),
(config) => (config.storage.customFolders = DEFAULT_CONFIG.storage.customFolders),
(config) => (config.database.external = DEFAULT_CONFIG.database.external),
(config) => (config.redis.external = DEFAULT_CONFIG.redis.external),
(config) => (config.containerNames = DEFAULT_CONFIG.containerNames),
(config) => (config.storage.externalLibraries = structuredClone(DEFAULT_CONFIG.storage.externalLibraries)),
(config) => (config.network.external = DEFAULT_CONFIG.network.external),
];
export type OutputContext = {
config: ImmichConfig;
server: YamlPath;
ml: YamlPath;
services: string[];
env: (key: string) => YamlPath[];
bundledDatabase: boolean;
databaseVolume: YamlPath[];
network: YamlPath[];
rootless: YamlPath[];
rootlessUser: YamlPath[];
overrides: YamlPath[];
libraries: YamlPath[];
fragment: (of: 'transcoding' | 'ml') => YamlPath[];
};
type FieldSpec = {
config: string[];
advanced?: true;
output?: (context: OutputContext) => YamlPath[];
};
const DATABASE = ['services', 'database'];
export const FIELDS = {
version: {
config: [],
output: ({ server, ml }) => [
[...server, 'image'],
[...ml, 'image'],
],
},
timezone: { config: ['timezone'], output: ({ env }) => env('TZ') },
port: { config: ['port'], advanced: true, output: ({ server }) => [[...server, 'ports', 0]] },
containerNames: {
config: ['containerNames'],
advanced: true,
output: ({ config, services }) =>
config.containerNames ? services.map((name) => ['services', name, 'container_name']) : [],
},
rootless: { config: ['rootless.enabled'], output: ({ rootless }) => rootless },
rootlessUser: { config: ['rootless.uid', 'rootless.gid'], output: ({ rootlessUser }) => rootlessUser },
uploadLocation: { config: ['storage.uploadLocation'], output: ({ server }) => [[...server, 'volumes', 0]] },
customFolders: { config: ['storage.customFolders'], advanced: true, output: ({ overrides }) => overrides },
externalLibraries: { config: ['storage.externalLibraries'], advanced: true, output: ({ libraries }) => libraries },
transcoding: { config: ['hwaccel.transcoding'], output: ({ fragment }) => fragment('transcoding') },
ml: { config: ['hwaccel.ml'], output: ({ ml, fragment }) => [[...ml, 'image'], ...fragment('ml')] },
databaseExternal: {
config: ['database.external'],
advanced: true,
output: ({ bundledDatabase, env }) => (bundledDatabase ? [DATABASE] : env('DB_URL')),
},
databaseMount: { config: ['database.mount'], output: ({ databaseVolume }) => databaseVolume },
databaseLocation: { config: ['database.mount.location'], output: ({ databaseVolume }) => databaseVolume },
databaseStorageType: {
config: ['database.storageType'],
output: ({ bundledDatabase }) => (bundledDatabase ? [[...DATABASE, 'environment', 'DB_STORAGE_TYPE']] : []),
},
databasePassword: {
config: ['database.password'],
output: ({ bundledDatabase, env }) => [
...(bundledDatabase ? [[...DATABASE, 'environment', 'POSTGRES_PASSWORD']] : []),
...env('DB_PASSWORD'),
],
},
externalUrl: { config: ['database.externalUrl'], output: ({ env }) => env('DB_URL') },
redisExternal: {
config: ['redis.external'],
advanced: true,
output: ({ env, services }) =>
services.includes('redis')
? [['services', 'redis']]
: [...env('REDIS_HOSTNAME'), ...env('REDIS_PORT'), ...env('REDIS_PASSWORD')],
},
redisHost: { config: ['redis.host'], output: ({ env }) => env('REDIS_HOSTNAME') },
redisPort: { config: ['redis.port'], output: ({ env }) => env('REDIS_PORT') },
redisPassword: { config: ['redis.password'], output: ({ env }) => env('REDIS_PASSWORD') },
networkExternal: { config: ['network.external'], advanced: true, output: ({ network }) => network },
networkName: { config: ['network.name'], output: ({ network }) => network },
} satisfies Record<string, FieldSpec>;
export type FieldId = keyof typeof FIELDS;
const valueAt = (source: unknown, path: string[]): unknown => {
let value = source;
for (const key of path) {
value = (value as Record<string, unknown> | undefined)?.[key];
}
return value;
};
const assignAt = (target: ImmichConfig, path: string, value: unknown) => {
const keys = path.split('.');
const parent = valueAt(target, keys.slice(0, -1)) as Record<string, unknown>;
parent[keys.at(-1) as string] = value;
};
export const withoutAdvanced = (config: ImmichConfig): ImmichConfig => {
const basic = structuredClone(config);
for (const reset of ADVANCED_RESETS) {
reset(basic);
for (const field of Object.values(FIELDS)) {
if (!('advanced' in field)) {
continue;
}
for (const path of field.config) {
const fallback = valueAt(DEFAULT_CONFIG, path.split('.'));
assignAt(basic, path, structuredClone(fallback));
}
}
return basic;
};
@@ -0,0 +1,119 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { buildCompose, buildComposeFields } from './build';
import { DEFAULT_CONFIG, FIELDS, type ImmichConfig } from './config';
import { sourceRanges } from './highlight';
const page = readFileSync(new URL('../../routes/docker-compose-builder/+page.svelte', import.meta.url), 'utf8');
const markers = page
.matchAll(/data-field="([^"]+)"/g)
.map((match) => match[1])
.toArray();
const families = page
.matchAll(/data-field=\{`([a-z]+):\$\{/gi)
.map((match) => match[1])
.toArray();
const withOverrides = (config: ImmichConfig) => {
config.storage.customFolders = true;
config.storage.overrides.thumbs = '/mnt/fast/thumbs';
config.storage.overrides.backups = '/mnt/slow/backups';
config.storage.externalLibraries = [
{ path: '/mnt/media/photos', readOnly: true },
{ path: ' ', readOnly: true },
{ path: '/mnt/media/family', readOnly: false },
];
return config;
};
const configs: [string, ImmichConfig][] = [
['defaults', DEFAULT_CONFIG],
[
'everything on',
(() => {
const config = withOverrides(structuredClone(DEFAULT_CONFIG));
config.timezone = 'Europe/Amsterdam';
config.port = 8080;
config.rootless = { enabled: true, uid: 99, gid: 100 };
config.containerNames = false;
config.hwaccel = { transcoding: 'rkmpp', ml: 'openvino' };
config.network = { external: true, name: 'proxy' };
return config;
})(),
],
[
'external services',
(() => {
const config = structuredClone(DEFAULT_CONFIG);
config.database.external = true;
config.database.externalUrl = 'postgresql://u:p@h:5432/immich';
config.redis = { external: true, host: 'redis.example.com', port: 6380, password: 'pw' };
return config;
})(),
],
[
'named database volume',
(() => {
const config = structuredClone(DEFAULT_CONFIG);
config.database.mount = { type: 'volume' };
return config;
})(),
],
];
const everything = configs[1][1];
describe('the field table, the builder and the page agree', () => {
it('every marker in the page is a declared field', () => {
const declared = new Set<string>(Object.keys(FIELDS));
expect(markers.filter((field) => !declared.has(field))).toEqual([]);
expect(families.toSorted()).toEqual(['library', 'override']);
});
it('every declared field is reported by the builder', () => {
const reported = buildComposeFields(everything, 'v3');
expect(Object.keys(FIELDS).filter((field) => !Object.hasOwn(reported, field))).toEqual([]);
});
it('every declared field is wired to a control in the page', () => {
const used = new Set([...markers, 'externalLibraries', 'customFolders']);
expect(Object.keys(FIELDS).filter((field) => !used.has(field))).toEqual([]);
});
it('advanced fields all name a config path to reset', () => {
for (const [id, field] of Object.entries(FIELDS)) {
if ('advanced' in field) {
expect(field.config.length, id).toBeGreaterThan(0);
}
}
});
});
describe('reported paths point at real output', () => {
for (const [name, config] of configs) {
it(`resolves every claimed path (${name})`, () => {
const text = buildCompose(config, 'v3');
const fields = buildComposeFields(config, 'v3');
const dangling = Object.entries(fields).flatMap(([field, paths]) =>
paths.filter((path) => sourceRanges(text, [path]).length === 0).map((path) => `${field} -> ${path.join('.')}`),
);
expect(dangling).toEqual([]);
});
}
it('maps external library rows to their own mount, skipping blanks', () => {
const text = buildCompose(everything, 'v3');
const fields = buildComposeFields(everything, 'v3');
const slice = (field: string) => sourceRanges(text, fields[field]).map(([start, end]) => text.slice(start, end));
expect(slice('library:0')).toEqual(['/mnt/media/photos:/mnt/media/photos:ro']);
expect(slice('library:2')).toEqual(['/mnt/media/family:/mnt/media/family']);
expect(fields['library:1']).toBeUndefined();
expect(slice('override:thumbs')).toEqual(['/mnt/fast/thumbs:/data/thumbs']);
expect(slice('override:backups')).toEqual(['/mnt/slow/backups:/data/backups']);
});
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { buildCompose } from './build';
import { DEFAULT_CONFIG } from './config';
import { highlightedLines, sourceRanges } from './highlight';
const text = (() => {
const config = structuredClone(DEFAULT_CONFIG);
config.timezone = 'Europe/Amsterdam';
return buildCompose(config, 'v3');
})();
const slice = (paths: Parameters<typeof sourceRanges>[1]) =>
sourceRanges(text, paths).map(([start, end]) => text.slice(start, end));
describe('sourceRanges', () => {
it('covers the whole key and value of a mapping', () => {
expect(slice([['services', 'immich-server', 'container_name']])).toEqual(['container_name: immich_server']);
expect(slice([['services', 'immich-server', 'environment', 'TZ']])).toEqual(['TZ: Europe/Amsterdam']);
});
it('covers a single sequence item', () => {
expect(slice([['services', 'immich-server', 'ports', 0]])).toEqual(['2283:2283']);
expect(slice([['services', 'immich-server', 'volumes', 0]])).toEqual(['./library:/data']);
});
it('resolves several paths at once, in order', () => {
expect(
slice([
['services', 'immich-server', 'container_name'],
['services', 'redis', 'container_name'],
['services', 'database', 'container_name'],
]),
).toEqual(['container_name: immich_server', 'container_name: immich_redis', 'container_name: immich_postgres']);
});
it('drops paths that are not present', () => {
expect(
slice([
['services', 'immich-server', 'nope'],
['services', 'nope', 'x'],
]),
).toEqual([]);
});
});
describe('highlightedLines', () => {
const lineAt = (index: number) => text.split('\n')[index];
it('returns the index of a single line', () => {
const lines = highlightedLines(text, [['services', 'immich-server', 'environment', 'TZ']]);
expect(lines).toHaveLength(1);
expect(lineAt(lines[0]).trim()).toBe('TZ: Europe/Amsterdam');
});
it('spans every line of a multi-line block', () => {
const lines = highlightedLines(text, [['services', 'immich-server', 'volumes']]);
expect(lines.length).toBeGreaterThan(1);
expect(lineAt(lines[0]).trim()).toBe('volumes:');
for (const index of lines.slice(1)) {
expect(lineAt(index).trimStart().startsWith('-')).toBe(true);
}
});
it('is empty when nothing matches', () => {
expect(highlightedLines(text, [['services', 'immich-server', 'nope']])).toEqual([]);
expect(highlightedLines(text, [])).toEqual([]);
});
});
@@ -0,0 +1,55 @@
import { isMap, isSeq, parseDocument, type Node } from 'yaml';
import type { YamlPath } from './spec';
type SourceRange = [start: number, end: number];
const rangeOf = (node: unknown): SourceRange | undefined => {
const range = (node as Node | undefined)?.range;
return range ? [range[0], range[1]] : undefined;
};
const pairRange = (document_: ReturnType<typeof parseDocument>, path: YamlPath): SourceRange | undefined => {
const key = path.at(-1);
const parent = path.length > 1 ? document_.getIn(path.slice(0, -1), true) : document_.contents;
if (isSeq(parent) && typeof key === 'number') {
return rangeOf(parent.items[key]);
}
if (isMap(parent)) {
const pair = parent.items.find((item) => (item.key as { value?: unknown })?.value === key);
if (!pair) {
return undefined;
}
const start = rangeOf(pair.key)?.[0];
const end = rangeOf(pair.value)?.[1];
return start === undefined || end === undefined ? undefined : [start, end];
}
return undefined;
};
export const sourceRanges = (text: string, paths: YamlPath[]): SourceRange[] => {
const document_ = parseDocument(text);
return paths.map((path) => pairRange(document_, path)).filter((range) => range !== undefined);
};
export const highlightedLines = (text: string, paths: YamlPath[]): number[] => {
const ranges = sourceRanges(text, paths);
if (ranges.length === 0) {
return [];
}
const lines: number[] = [];
let start = 0;
for (const [index, line] of text.split('\n').entries()) {
const end = start + line.length;
if (ranges.some(([from, to]) => from < end && to > start)) {
lines.push(index);
}
start = end + 1;
}
return lines;
};
@@ -4,3 +4,7 @@ export type { Service as ComposeService } from '@json-types/compose';
// The generated Volume type omits the null the compose schema allows for a default volume.
export type ComposeFile = Omit<Compose, 'volumes'> & { volumes?: Record<string, null> };
export type YamlPath = (string | number)[];
export type FieldPaths = Record<string, YamlPath[]>;
@@ -2,9 +2,10 @@
import { goto } from '$app/navigation';
import { siteMetadata } from '$lib';
import PageContent from '$lib/components/PageContent.svelte';
import { buildCompose } from '$lib/compose/build';
import { buildCompose, buildComposeFields } from '$lib/compose/build';
import { DEFAULT_CONFIG, FOLDER_OVERRIDES, StorageType, validate, withoutAdvanced } from '$lib/compose/config';
import { ML_ACCELS, TRANSCODE_ACCELS } from '$lib/compose/hwaccel';
import { highlightedLines } from '$lib/compose/highlight';
import {
ActionBar,
ActionButton,
@@ -72,6 +73,23 @@
const compose = $derived(buildCompose(effectiveConfig, version));
const errors = $derived(validate(effectiveConfig));
const hasErrors = $derived(Object.keys(errors).length > 0);
const fields = $derived(buildComposeFields(effectiveConfig, version));
let focusedField = $state<string | undefined>();
const onFocusIn = (event: FocusEvent) => {
const target = (event.target as HTMLElement | null)?.closest<HTMLElement>('[data-field]');
focusedField = target?.dataset.field;
};
const onFocusOut = (event: FocusEvent) => {
const next = (event.relatedTarget as HTMLElement | null)?.closest('[data-field]');
if (!next) {
focusedField = undefined;
}
};
const focusedLines = $derived(focusedField ? highlightedLines(compose, fields[focusedField] ?? []) : []);
const timezones = Intl.supportedValuesOf('timeZone');
@@ -208,7 +226,12 @@
<ActionButton action={Download} type="button" variant="filled" size="medium" shape="round" color="primary" />
</div>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[clamp(20rem,34%,31.25rem)_1fr]">
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
<form
autocomplete="off"
onsubmit={(event) => event.preventDefault()}
onfocusin={onFocusIn}
onfocusout={onFocusOut}
>
<Stack gap={6}>
<Card color="secondary">
<CardHeader>
@@ -225,6 +248,7 @@
fullWidth
variant={versionPinned ? 'ghost' : 'filled'}
color={versionPinned ? 'secondary' : 'primary'}
data-field="version"
onclick={() => (versionPinned = false)}
>
Rolling
@@ -234,6 +258,7 @@
fullWidth
variant={versionPinned ? 'filled' : 'ghost'}
color={versionPinned ? 'primary' : 'secondary'}
data-field="version"
onclick={() => (versionPinned = true)}
>
Pinned
@@ -242,6 +267,7 @@
{#if versionPinned}
<Input
bind:value={pinnedVersion}
data-field="version"
placeholder={latestVersion}
aria-label="Pinned Immich version tag"
/>
@@ -263,13 +289,18 @@
</Field>
<Field label="Rootless mode">
<Switch bind:checked={config.rootless.enabled} class="flex justify-between gap-4" />
<Switch
bind:checked={config.rootless.enabled}
data-field="rootless"
class="flex justify-between gap-4"
/>
</Field>
{#if config.rootless.enabled}
<div class="grid grid-cols-2 gap-4">
<Field label="UID" invalid={!!errors['rootless.uid']}>
<NumberInput
bind:value={config.rootless.uid}
data-field="rootlessUser"
min={0}
placeholder={String(DEFAULT_CONFIG.rootless.uid)}
/>
@@ -278,6 +309,7 @@
<Field label="GID" invalid={!!errors['rootless.gid']}>
<NumberInput
bind:value={config.rootless.gid}
data-field="rootlessUser"
min={0}
placeholder={String(DEFAULT_CONFIG.rootless.gid)}
/>
@@ -291,7 +323,11 @@
label="Container names"
description="Turn off to run more than one Immich stack on the same host."
>
<Switch bind:checked={config.containerNames} class="flex justify-between gap-4" />
<Switch
bind:checked={config.containerNames}
data-field="containerNames"
class="flex justify-between gap-4"
/>
</Field>
{/if}
</Stack>
@@ -305,7 +341,12 @@
<CardBody>
<Stack gap={4}>
<Field label="Server Timezone">
<Input bind:value={config.timezone} list="timezones" placeholder="Etc/UTC" />
<Input
bind:value={config.timezone}
data-field="timezone"
list="timezones"
placeholder="Etc/UTC"
/>
<!-- TODO: Combobox in @immich/ui -->
<datalist id="timezones">
{#each timezones as timezone (timezone)}
@@ -317,6 +358,7 @@
<Field label="Upload Location" invalid={!!errors['storage.uploadLocation']}>
<Input
bind:value={config.storage.uploadLocation}
data-field="uploadLocation"
placeholder={DEFAULT_CONFIG.storage.uploadLocation}
/>
{@render fieldError(errors['storage.uploadLocation'])}
@@ -327,7 +369,11 @@
label="Custom folder locations"
description="Mount individual subfolders on separate storage."
>
<Switch bind:checked={config.storage.customFolders} class="flex justify-between gap-4" />
<Switch
bind:checked={config.storage.customFolders}
data-field="customFolders"
class="flex justify-between gap-4"
/>
</Field>
{#if config.storage.customFolders}
<Text size="small" color="muted">Leave a field blank to keep it under the upload location.</Text
@@ -335,7 +381,11 @@
{#each FOLDER_OVERRIDES as folder (folder.key)}
{@const error = errors[`storage.overrides.${folder.key}`]}
<Field label={folder.label} invalid={!!error}>
<Input bind:value={config.storage.overrides[folder.key]} placeholder="Optional host path" />
<Input
bind:value={config.storage.overrides[folder.key]}
data-field={`override:${folder.key}`}
placeholder="Optional host path"
/>
{@render fieldError(error)}
</Field>
{/each}
@@ -354,6 +404,7 @@
<div class="grow">
<Input
bind:value={library.path}
data-field={`library:${index}`}
placeholder="/mnt/media/photos"
aria-label={`External library path ${index + 1}`}
/>
@@ -393,6 +444,7 @@
<Field label="Host Port" invalid={!!errors.port}>
<NumberInput
bind:value={config.port}
data-field="port"
min={1}
max={65_535}
placeholder={String(DEFAULT_CONFIG.port)}
@@ -401,18 +453,24 @@
</Field>
<Field label="External network" description="Attach the server to a network you already created.">
<Switch bind:checked={config.network.external} class="flex justify-between gap-4" />
<Switch
bind:checked={config.network.external}
data-field="networkExternal"
class="flex justify-between gap-4"
/>
</Field>
{#if config.network.external}
<Field label="Network name" invalid={!!errors['network.name']}>
<Input bind:value={config.network.name} placeholder="proxy" />
<Input bind:value={config.network.name} data-field="networkName" placeholder="proxy" />
{@render fieldError(errors['network.name'])}
</Field>
{/if}
{/if}
<Field label="Transcoding Hardware Acceleration">
<div data-field="transcoding">
<Select bind:value={config.hwaccel.transcoding} options={TRANSCODE_ACCELS} />
</div>
</Field>
</Stack>
</CardBody>
@@ -424,7 +482,9 @@
</CardHeader>
<CardBody>
<Field label="Hardware Acceleration">
<div data-field="ml">
<Select bind:value={config.hwaccel.ml} options={ML_ACCELS} />
</div>
</Field>
</CardBody>
</Card>
@@ -437,7 +497,11 @@
<Stack gap={4}>
{#if advanced}
<Field label="External Postgres">
<Switch bind:checked={config.database.external} class="flex justify-between gap-4" />
<Switch
bind:checked={config.database.external}
data-field="databaseExternal"
class="flex justify-between gap-4"
/>
</Field>
{/if}
{#if advanced && config.database.external}
@@ -450,6 +514,7 @@
<Field label="Connection URL" invalid={!!errors['database.externalUrl']}>
<Input
bind:value={config.database.externalUrl}
data-field="externalUrl"
placeholder="postgresql://user:password@host:5432/immich"
/>
{@render fieldError(errors['database.externalUrl'])}
@@ -460,6 +525,7 @@
description="Docker manages the storage location. Recommended on Windows and macOS."
>
<Switch
data-field="databaseMount"
checked={config.database.mount.type === 'volume'}
onCheckedChange={(value) =>
(config.database.mount = value
@@ -470,19 +536,26 @@
</Field>
{#if config.database.mount.type === 'bind'}
<Field label="Database Location" invalid={!!errors['database.mount.location']}>
<Input bind:value={config.database.mount.location} placeholder={defaultDatabaseLocation} />
<Input
bind:value={config.database.mount.location}
data-field="databaseLocation"
placeholder={defaultDatabaseLocation}
/>
{@render fieldError(errors['database.mount.location'])}
</Field>
{/if}
<Field label="Database storage type">
<div data-field="databaseStorageType">
<Select bind:value={config.database.storageType} options={Object.values(StorageType)} />
</div>
</Field>
<Field label="Database Password" invalid={!!errors['database.password']}>
<div class="flex items-end gap-2">
<div class="grow">
<Input bind:value={config.database.password} />
<Input bind:value={config.database.password} data-field="databasePassword" />
</div>
<IconButton
data-field="databasePassword"
icon={mdiDiceMultiple}
onclick={generatePassword}
aria-label="Generate a random password"
@@ -505,19 +578,33 @@
<CardBody>
<Stack gap={4}>
<Field label="External Redis">
<Switch bind:checked={config.redis.external} class="flex justify-between gap-4" />
<Switch
bind:checked={config.redis.external}
data-field="redisExternal"
class="flex justify-between gap-4"
/>
</Field>
{#if config.redis.external}
<Field label="Host" invalid={!!errors['redis.host']}>
<Input bind:value={config.redis.host} placeholder="redis.example.com" />
<Input
bind:value={config.redis.host}
data-field="redisHost"
placeholder="redis.example.com"
/>
{@render fieldError(errors['redis.host'])}
</Field>
<Field label="Port" invalid={!!errors['redis.port']}>
<NumberInput bind:value={config.redis.port} min={1} max={65_535} placeholder="6379" />
<NumberInput
bind:value={config.redis.port}
data-field="redisPort"
min={1}
max={65_535}
placeholder="6379"
/>
{@render fieldError(errors['redis.port'])}
</Field>
<Field label="Password">
<Input bind:value={config.redis.password} placeholder="Optional" />
<Input bind:value={config.redis.password} data-field="redisPassword" placeholder="Optional" />
</Field>
{/if}
</Stack>
@@ -531,7 +618,13 @@
{#if versionFailed}
<Text color="danger">Could not reach the Immich version service. Reload the page to try again.</Text>
{:else if version}
<CodeBlock code={compose} language={yamlLanguage} lineNumbers copy={!hasErrors} />
<CodeBlock
code={compose}
language={yamlLanguage}
lineNumbers
highlightedLines={focusedLines}
copy={!hasErrors}
/>
{:else}
<LoadingSpinner />
{/if}