mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
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:
co-authored by
Jason Rasmussen
parent
91114a8a11
commit
7462546471
@@ -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: '' });
|
||||
|
||||
@@ -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">
|
||||
<Select bind:value={config.hwaccel.transcoding} options={TRANSCODE_ACCELS} />
|
||||
<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">
|
||||
<Select bind:value={config.hwaccel.ml} options={ML_ACCELS} />
|
||||
<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">
|
||||
<Select bind:value={config.database.storageType} options={Object.values(StorageType)} />
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user