1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat(server,web): server config (#4006)

* feat: server config

* chore: open api

* fix: redirect /map to /photos when disabled
This commit is contained in:
Jason Rasmussen 2023-09-08 22:51:46 -04:00 committed by GitHub
parent 3edade6761
commit f1db257628
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 658 additions and 160 deletions

View File

@ -2343,6 +2343,31 @@ export interface SearchResponseDto {
*/
'assets': SearchAssetResponseDto;
}
/**
*
* @export
* @interface ServerConfigDto
*/
export interface ServerConfigDto {
/**
*
* @type {string}
* @memberof ServerConfigDto
*/
'loginPageMessage': string;
/**
*
* @type {string}
* @memberof ServerConfigDto
*/
'mapTileUrl': string;
/**
*
* @type {string}
* @memberof ServerConfigDto
*/
'oauthButtonText': string;
}
/**
*
* @export
@ -2367,6 +2392,12 @@ export interface ServerFeaturesDto {
* @memberof ServerFeaturesDto
*/
'facialRecognition': boolean;
/**
*
* @type {boolean}
* @memberof ServerFeaturesDto
*/
'map': boolean;
/**
*
* @type {boolean}
@ -2810,6 +2841,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto
*/
'machineLearning': SystemConfigMachineLearningDto;
/**
*
* @type {SystemConfigMapDto}
* @memberof SystemConfigDto
*/
'map': SystemConfigMapDto;
/**
*
* @type {SystemConfigOAuthDto}
@ -3050,6 +3087,25 @@ export interface SystemConfigMachineLearningDto {
*/
'url': string;
}
/**
*
* @export
* @interface SystemConfigMapDto
*/
export interface SystemConfigMapDto {
/**
*
* @type {boolean}
* @memberof SystemConfigMapDto
*/
'enabled': boolean;
/**
*
* @type {string}
* @memberof SystemConfigMapDto
*/
'tileUrl': string;
}
/**
*
* @export
@ -10825,6 +10881,35 @@ export class SearchApi extends BaseAPI {
*/
export const ServerInfoApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getServerConfig: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/server-info/config`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
@ -11027,6 +11112,15 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
export const ServerInfoApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ServerInfoApiAxiosParamCreator(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getServerConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerConfigDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getServerConfig(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
@ -11091,6 +11185,14 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
export const ServerInfoApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ServerInfoApiFp(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getServerConfig(options?: AxiosRequestConfig): AxiosPromise<ServerConfigDto> {
return localVarFp.getServerConfig(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
@ -11149,6 +11251,16 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
* @extends {BaseAPI}
*/
export class ServerInfoApi extends BaseAPI {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ServerInfoApi
*/
public getServerConfig(options?: AxiosRequestConfig) {
return ServerInfoApiFp(this.configuration).getServerConfig(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.

View File

@ -97,6 +97,7 @@ doc/SearchExploreResponseDto.md
doc/SearchFacetCountResponseDto.md
doc/SearchFacetResponseDto.md
doc/SearchResponseDto.md
doc/ServerConfigDto.md
doc/ServerFeaturesDto.md
doc/ServerInfoApi.md
doc/ServerInfoResponseDto.md
@ -116,6 +117,7 @@ doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md
doc/SystemConfigJobDto.md
doc/SystemConfigMachineLearningDto.md
doc/SystemConfigMapDto.md
doc/SystemConfigOAuthDto.md
doc/SystemConfigPasswordLoginDto.md
doc/SystemConfigStorageTemplateDto.md
@ -249,6 +251,7 @@ lib/model/search_explore_response_dto.dart
lib/model/search_facet_count_response_dto.dart
lib/model/search_facet_response_dto.dart
lib/model/search_response_dto.dart
lib/model/server_config_dto.dart
lib/model/server_features_dto.dart
lib/model/server_info_response_dto.dart
lib/model/server_media_types_response_dto.dart
@ -265,6 +268,7 @@ lib/model/system_config_dto.dart
lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_job_dto.dart
lib/model/system_config_machine_learning_dto.dart
lib/model/system_config_map_dto.dart
lib/model/system_config_o_auth_dto.dart
lib/model/system_config_password_login_dto.dart
lib/model/system_config_storage_template_dto.dart
@ -382,6 +386,7 @@ test/search_explore_response_dto_test.dart
test/search_facet_count_response_dto_test.dart
test/search_facet_response_dto_test.dart
test/search_response_dto_test.dart
test/server_config_dto_test.dart
test/server_features_dto_test.dart
test/server_info_api_test.dart
test/server_info_response_dto_test.dart
@ -401,6 +406,7 @@ test/system_config_dto_test.dart
test/system_config_f_fmpeg_dto_test.dart
test/system_config_job_dto_test.dart
test/system_config_machine_learning_dto_test.dart
test/system_config_map_dto_test.dart
test/system_config_o_auth_dto_test.dart
test/system_config_password_login_dto_test.dart
test/system_config_storage_template_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

BIN
mobile/openapi/doc/ServerConfigDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SystemConfigMapDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -3342,6 +3342,27 @@
]
}
},
"/server-info/config": {
"get": {
"operationId": "getServerConfig",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerConfigDto"
}
}
},
"description": ""
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/features": {
"get": {
"operationId": "getServerFeatures",
@ -6618,6 +6639,25 @@
],
"type": "object"
},
"ServerConfigDto": {
"properties": {
"loginPageMessage": {
"type": "string"
},
"mapTileUrl": {
"type": "string"
},
"oauthButtonText": {
"type": "string"
}
},
"required": [
"oauthButtonText",
"loginPageMessage",
"mapTileUrl"
],
"type": "object"
},
"ServerFeaturesDto": {
"properties": {
"clipEncode": {
@ -6629,6 +6669,9 @@
"facialRecognition": {
"type": "boolean"
},
"map": {
"type": "boolean"
},
"oauth": {
"type": "boolean"
},
@ -6649,15 +6692,16 @@
}
},
"required": [
"configFile",
"clipEncode",
"configFile",
"facialRecognition",
"sidecar",
"search",
"tagImage",
"map",
"oauth",
"oauthAutoLaunch",
"passwordLogin"
"passwordLogin",
"sidecar",
"search",
"tagImage"
],
"type": "object"
},
@ -6989,6 +7033,9 @@
"machineLearning": {
"$ref": "#/components/schemas/SystemConfigMachineLearningDto"
},
"map": {
"$ref": "#/components/schemas/SystemConfigMapDto"
},
"oauth": {
"$ref": "#/components/schemas/SystemConfigOAuthDto"
},
@ -7005,6 +7052,7 @@
"required": [
"ffmpeg",
"machineLearning",
"map",
"oauth",
"passwordLogin",
"storageTemplate",
@ -7162,6 +7210,21 @@
],
"type": "object"
},
"SystemConfigMapDto": {
"properties": {
"enabled": {
"type": "boolean"
},
"tileUrl": {
"type": "string"
}
},
"required": [
"enabled",
"tileUrl"
],
"type": "object"
},
"SystemConfigOAuthDto": {
"properties": {
"autoLaunch": {

View File

@ -79,16 +79,21 @@ export class ServerMediaTypesResponseDto {
sidecar!: string[];
}
export class ServerFeaturesDto implements FeatureFlags {
configFile!: boolean;
clipEncode!: boolean;
facialRecognition!: boolean;
sidecar!: boolean;
search!: boolean;
tagImage!: boolean;
export class ServerConfigDto {
oauthButtonText!: string;
loginPageMessage!: string;
mapTileUrl!: string;
}
// TODO: use these instead of `POST oauth/config`
export class ServerFeaturesDto implements FeatureFlags {
clipEncode!: boolean;
configFile!: boolean;
facialRecognition!: boolean;
map!: boolean;
oauth!: boolean;
oauthAutoLaunch!: boolean;
passwordLogin!: boolean;
sidecar!: boolean;
search!: boolean;
tagImage!: boolean;
}

View File

@ -143,22 +143,34 @@ describe(ServerInfoService.name, () => {
it('should respond the server version', () => {
expect(sut.getVersion()).toEqual(serverVersion);
});
});
describe('getFeatures', () => {
it('should respond the server features', async () => {
await expect(sut.getFeatures()).resolves.toEqual({
clipEncode: true,
facialRecognition: true,
oauth: false,
oauthAutoLaunch: false,
passwordLogin: true,
search: true,
sidecar: true,
tagImage: true,
configFile: false,
});
expect(configMock.load).toHaveBeenCalled();
describe('getFeatures', () => {
it('should respond the server features', async () => {
await expect(sut.getFeatures()).resolves.toEqual({
clipEncode: true,
facialRecognition: true,
map: true,
oauth: false,
oauthAutoLaunch: false,
passwordLogin: true,
search: true,
sidecar: true,
tagImage: true,
configFile: false,
});
expect(configMock.load).toHaveBeenCalled();
});
});
describe('getConfig', () => {
it('should respond the server configuration', async () => {
await expect(sut.getConfig()).resolves.toEqual({
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
});
expect(configMock.load).toHaveBeenCalled();
});
});

View File

@ -5,6 +5,7 @@ import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { ISystemConfigRepository, SystemConfigCore } from '../system-config';
import { IUserRepository, UserStatsQueryResponse } from '../user';
import {
ServerConfigDto,
ServerFeaturesDto,
ServerInfoResponseDto,
ServerMediaTypesResponseDto,
@ -55,6 +56,19 @@ export class ServerInfoService {
return this.configCore.getFeatures();
}
async getConfig(): Promise<ServerConfigDto> {
const config = await this.configCore.getConfig();
// TODO move to system config
const loginPageMessage = process.env.PUBLIC_LOGIN_PAGE_MESSAGE || '';
return {
loginPageMessage,
mapTileUrl: config.map.tileUrl,
oauthButtonText: config.oauth.buttonText,
};
}
async getStats(): Promise<ServerStatsResponseDto> {
const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
const serverStats = new ServerStatsResponseDto();

View File

@ -0,0 +1,9 @@
import { IsBoolean, IsString } from 'class-validator';
export class SystemConfigMapDto {
@IsBoolean()
enabled!: boolean;
@IsString()
tileUrl!: string;
}

View File

@ -5,6 +5,7 @@ import { IsObject, ValidateNested } from 'class-validator';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigJobDto } from './system-config-job.dto';
import { SystemConfigMachineLearningDto } from './system-config-machine-learning.dto';
import { SystemConfigMapDto } from './system-config-map.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto';
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
@ -20,6 +21,11 @@ export class SystemConfigDto implements SystemConfig {
@IsObject()
machineLearning!: SystemConfigMachineLearningDto;
@Type(() => SystemConfigMapDto)
@ValidateNested()
@IsObject()
map!: SystemConfigMapDto;
@Type(() => SystemConfigOAuthDto)
@ValidateNested()
@IsObject()

View File

@ -55,7 +55,6 @@ export const defaults = Object.freeze<SystemConfig>({
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
},
machineLearning: {
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
url: process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003',
@ -75,6 +74,10 @@ export const defaults = Object.freeze<SystemConfig>({
maxDistance: 0.6,
},
},
map: {
enabled: true,
tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
},
oauth: {
enabled: false,
issuerUrl: '',
@ -108,6 +111,7 @@ export enum FeatureFlag {
CLIP_ENCODE = 'clipEncode',
FACIAL_RECOGNITION = 'facialRecognition',
TAG_IMAGE = 'tagImage',
MAP = 'map',
SIDECAR = 'sidecar',
SEARCH = 'search',
OAUTH = 'oauth',
@ -169,6 +173,7 @@ export class SystemConfigCore {
[FeatureFlag.CLIP_ENCODE]: mlEnabled && config.machineLearning.clip.enabled,
[FeatureFlag.FACIAL_RECOGNITION]: mlEnabled && config.machineLearning.facialRecognition.enabled,
[FeatureFlag.TAG_IMAGE]: mlEnabled && config.machineLearning.classification.enabled,
[FeatureFlag.MAP]: config.map.enabled,
[FeatureFlag.SIDECAR]: true,
[FeatureFlag.SEARCH]: process.env.TYPESENSE_ENABLED !== 'false',

View File

@ -73,6 +73,10 @@ const updatedConfig = Object.freeze<SystemConfig>({
maxDistance: 0.6,
},
},
map: {
enabled: true,
tileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
},
oauth: {
autoLaunch: true,
autoRegister: true,

View File

@ -1,4 +1,5 @@
import {
ServerConfigDto,
ServerFeaturesDto,
ServerInfoResponseDto,
ServerInfoService,
@ -42,6 +43,12 @@ export class ServerInfoController {
return this.service.getFeatures();
}
@PublicRoute()
@Get('config')
getServerConfig(): Promise<ServerConfigDto> {
return this.service.getConfig();
}
@AdminRoute()
@Get('stats')
getStats(): Promise<ServerStatsResponseDto> {

View File

@ -58,6 +58,9 @@ export enum SystemConfigKey {
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_SCORE = 'machineLearning.facialRecognition.minScore',
MACHINE_LEARNING_FACIAL_RECOGNITION_MAX_DISTANCE = 'machineLearning.facialRecognition.maxDistance',
MAP_ENABLED = 'map.enabled',
MAP_TILE_URL = 'map.tileUrl',
OAUTH_ENABLED = 'oauth.enabled',
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_CLIENT_ID = 'oauth.clientId',
@ -164,6 +167,10 @@ export interface SystemConfig {
maxDistance: number;
};
};
map: {
enabled: boolean;
tileUrl: string;
};
oauth: {
enabled: boolean;
issuerUrl: string;

View File

@ -83,6 +83,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
clipEncode: true,
configFile: false,
facialRecognition: true,
map: true,
oauth: false,
oauthAutoLaunch: false,
passwordLogin: true,
@ -93,6 +94,18 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
});
});
describe('GET /server-info/config', () => {
it('should respond with the server configuration', async () => {
const { status, body } = await request(server).get('/server-info/config');
expect(status).toBe(200);
expect(body).toEqual({
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
mapTileUrl: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
});
});
});
describe('GET /server-info/stats', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/server-info/stats');

View File

@ -2343,6 +2343,31 @@ export interface SearchResponseDto {
*/
'assets': SearchAssetResponseDto;
}
/**
*
* @export
* @interface ServerConfigDto
*/
export interface ServerConfigDto {
/**
*
* @type {string}
* @memberof ServerConfigDto
*/
'loginPageMessage': string;
/**
*
* @type {string}
* @memberof ServerConfigDto
*/
'mapTileUrl': string;
/**
*
* @type {string}
* @memberof ServerConfigDto
*/
'oauthButtonText': string;
}
/**
*
* @export
@ -2367,6 +2392,12 @@ export interface ServerFeaturesDto {
* @memberof ServerFeaturesDto
*/
'facialRecognition': boolean;
/**
*
* @type {boolean}
* @memberof ServerFeaturesDto
*/
'map': boolean;
/**
*
* @type {boolean}
@ -2810,6 +2841,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto
*/
'machineLearning': SystemConfigMachineLearningDto;
/**
*
* @type {SystemConfigMapDto}
* @memberof SystemConfigDto
*/
'map': SystemConfigMapDto;
/**
*
* @type {SystemConfigOAuthDto}
@ -3050,6 +3087,25 @@ export interface SystemConfigMachineLearningDto {
*/
'url': string;
}
/**
*
* @export
* @interface SystemConfigMapDto
*/
export interface SystemConfigMapDto {
/**
*
* @type {boolean}
* @memberof SystemConfigMapDto
*/
'enabled': boolean;
/**
*
* @type {string}
* @memberof SystemConfigMapDto
*/
'tileUrl': string;
}
/**
*
* @export
@ -10825,6 +10881,35 @@ export class SearchApi extends BaseAPI {
*/
export const ServerInfoApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getServerConfig: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/server-info/config`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
@ -11027,6 +11112,15 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
export const ServerInfoApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ServerInfoApiAxiosParamCreator(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getServerConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerConfigDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getServerConfig(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
@ -11091,6 +11185,14 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
export const ServerInfoApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ServerInfoApiFp(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getServerConfig(options?: AxiosRequestConfig): AxiosPromise<ServerConfigDto> {
return localVarFp.getServerConfig(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
@ -11149,6 +11251,16 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
* @extends {BaseAPI}
*/
export class ServerInfoApi extends BaseAPI {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ServerInfoApi
*/
public getServerConfig(options?: AxiosRequestConfig) {
return ServerInfoApiFp(this.configuration).getServerConfig(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.

View File

@ -4,7 +4,7 @@
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants';
import { featureFlags } from '$lib/stores/feature-flags.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import { AllJobStatusResponseDto, api, JobCommand, JobCommandDto, JobName } from '@api';
import type { ComponentType } from 'svelte';

View File

@ -0,0 +1,98 @@
<script lang="ts">
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api, SystemConfigMapDto } from '@api';
import { isEqual } from 'lodash-es';
import { fade } from 'svelte/transition';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import SettingSwitch from '../setting-switch.svelte';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
export let mapConfig: SystemConfigMapDto; // this is the config that is being edited
export let disabled = false;
let savedConfig: SystemConfigMapDto;
let defaultConfig: SystemConfigMapDto;
async function getConfigs() {
[savedConfig, defaultConfig] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.map),
api.systemConfigApi.getDefaults().then((res) => res.data.map),
]);
}
async function saveSetting() {
try {
const { data: current } = await api.systemConfigApi.getConfig();
const { data: updated } = await api.systemConfigApi.updateConfig({
systemConfigDto: { ...current, map: mapConfig },
});
mapConfig = { ...updated.map };
savedConfig = { ...updated.map };
notificationController.show({ message: 'Settings saved', type: NotificationType.Info });
} catch (error) {
handleError(error, 'Unable to save settings');
}
}
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
mapConfig = { ...resetConfig.map };
savedConfig = { ...resetConfig.map };
notificationController.show({
message: 'Reset settings to the recent saved settings',
type: NotificationType.Info,
});
}
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
mapConfig = { ...configs.map };
defaultConfig = { ...configs.map };
notificationController.show({
message: 'Reset map settings to default',
type: NotificationType.Info,
});
}
</script>
<div>
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSwitch title="ENABLED" {disabled} subtitle="Enable map features" bind:checked={mapConfig.enabled} />
<hr />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Tile URL"
desc="URL to a leaflet compatible tile server"
bind:value={mapConfig.tileUrl}
required={true}
disabled={disabled || !mapConfig.enabled}
isEdited={mapConfig.tileUrl !== savedConfig.tileUrl}
/>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!isEqual(savedConfig, defaultConfig)}
{disabled}
/>
</div>
</form>
</div>
{/await}
</div>

View File

@ -1,18 +1,19 @@
<script lang="ts">
import { page } from '$app/stores';
import { locale } from '$lib/stores/preferences.store';
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
import { getAssetFilename } from '$lib/utils/asset-utils';
import { AlbumResponseDto, AssetResponseDto, ThumbnailFormat, api } from '@api';
import type { LatLngTuple } from 'leaflet';
import { DateTime } from 'luxon';
import { createEventDispatcher } from 'svelte';
import Calendar from 'svelte-material-icons/Calendar.svelte';
import CameraIris from 'svelte-material-icons/CameraIris.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
import MapMarkerOutline from 'svelte-material-icons/MapMarkerOutline.svelte';
import { createEventDispatcher } from 'svelte';
import { AssetResponseDto, AlbumResponseDto, api, ThumbnailFormat } from '@api';
import { asByteUnitString } from '../../utils/byte-units';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import { getAssetFilename } from '$lib/utils/asset-utils';
import UserAvatar from '../shared-components/user-avatar.svelte';
export let asset: AssetResponseDto;
@ -268,12 +269,12 @@
</div>
</section>
{#if latlng}
{#if latlng && $featureFlags.loaded && $featureFlags.map}
<div class="h-[360px]">
{#await import('../shared-components/leaflet') then { Map, TileLayer, Marker }}
<Map center={latlng} zoom={14}>
<TileLayer
urlTemplate={'https://tile.openstreetmap.org/{z}/{x}/{y}.png'}
urlTemplate={$serverConfig.mapTileUrl}
options={{
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}}

View File

@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { AppRoute } from '$lib/constants';
import { featureFlags } from '$lib/stores/feature-flags.store';
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
import { api, oauth } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
@ -158,7 +158,7 @@
<LoadingSpinner />
</span>
{:else}
{$featureFlags.passwordLogin ? 'Login with OAuth' : 'Login'}
{$serverConfig.oauthButtonText}
{/if}
</Button>
</div>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { TileLayer, type TileLayerOptions } from 'leaflet';
import { onDestroy, onMount } from 'svelte';
import { getMapContext } from './map.svelte';
export let urlTemplate: string;
@ -15,6 +15,6 @@
});
onDestroy(() => {
if (tileLayer) tileLayer.remove();
tileLayer?.remove();
});
</script>

View File

@ -16,7 +16,7 @@
import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
import Cog from 'svelte-material-icons/Cog.svelte';
import UserAvatar from '../user-avatar.svelte';
import { featureFlags } from '$lib/stores/feature-flags.store';
import { featureFlags } from '$lib/stores/server-config.store';
export let user: UserResponseDto;
export let showUploadButton = true;

View File

@ -17,7 +17,7 @@
import SideBarButton from './side-bar-button.svelte';
import { locale } from '$lib/stores/preferences.store';
import SideBarSection from './side-bar-section.svelte';
import { featureFlags } from '$lib/stores/feature-flags.store';
import { featureFlags } from '$lib/stores/server-config.store';
const getStats = async (dto: AssetApiGetAssetStatsRequest) => {
const { data: stats } = await api.assetApi.getAssetStats(dto);
@ -62,9 +62,11 @@
<SideBarButton title="Explore" logo={Magnify} isSelected={$page.route.id === '/(user)/explore'} />
</a>
{/if}
<a data-sveltekit-preload-data="hover" href={AppRoute.MAP} draggable="false">
<SideBarButton title="Map" logo={Map} isSelected={$page.route.id === '/(user)/map'} />
</a>
{#if $featureFlags.map}
<a data-sveltekit-preload-data="hover" href={AppRoute.MAP} draggable="false">
<SideBarButton title="Map" logo={Map} isSelected={$page.route.id === '/(user)/map'} />
</a>
{/if}
<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
<SideBarButton
title="Sharing"

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { featureFlags } from '$lib/stores/feature-flags.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { oauth, UserResponseDto } from '@api';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { browser } from '$app/environment';
import { page } from '$app/stores';
import { featureFlags } from '$lib/stores/feature-flags.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { APIKeyResponseDto, AuthDeviceResponseDto, oauth, UserResponseDto } from '@api';
import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
import ChangePasswordSettings from './change-password-settings.svelte';

View File

@ -1,6 +1,3 @@
import { env } from '$env/dynamic/public';
export const loginPageMessage: string | undefined = env.PUBLIC_LOGIN_PAGE_MESSAGE;
export enum AssetAction {
ARCHIVE = 'archive',
UNARCHIVE = 'unarchive',

View File

@ -1,22 +0,0 @@
import { api, ServerFeaturesDto } from '@api';
import { writable } from 'svelte/store';
export type FeatureFlags = ServerFeaturesDto & { loaded: boolean };
export const featureFlags = writable<FeatureFlags>({
loaded: false,
clipEncode: true,
facialRecognition: true,
sidecar: true,
tagImage: true,
search: true,
oauth: false,
oauthAutoLaunch: false,
passwordLogin: true,
configFile: false,
});
export const loadFeatureFlags = async () => {
const { data } = await api.serverInfoApi.getServerFeatures();
featureFlags.update(() => ({ ...data, loaded: true }));
};

View File

@ -0,0 +1,37 @@
import { api, ServerConfigDto, ServerFeaturesDto } from '@api';
import { writable } from 'svelte/store';
export type FeatureFlags = ServerFeaturesDto & { loaded: boolean };
export const featureFlags = writable<FeatureFlags>({
loaded: false,
clipEncode: true,
facialRecognition: true,
sidecar: true,
tagImage: true,
map: true,
search: true,
oauth: false,
oauthAutoLaunch: false,
passwordLogin: true,
configFile: false,
});
export type ServerConfig = ServerConfigDto & { loaded: boolean };
export const serverConfig = writable<ServerConfig>({
loaded: false,
oauthButtonText: '',
mapTileUrl: '',
loginPageMessage: '',
});
export const loadConfig = async () => {
const [{ data: flags }, { data: config }] = await Promise.all([
api.serverInfoApi.getServerFeatures(),
api.serverInfoApi.getServerConfig(),
]);
featureFlags.update(() => ({ ...flags, loaded: true }));
serverConfig.update(() => ({ ...config, loaded: true }));
};

View File

@ -1,16 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AssetViewer from '$lib/components/asset-viewer/asset-viewer.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import MapSettingsModal from '$lib/components/map-page/map-settings-modal.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { AppRoute } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mapSettings } from '$lib/stores/preferences.store';
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
import { MapMarkerResponseDto, api } from '@api';
import { isEqual, omit } from 'lodash-es';
import { DateTime, Duration } from 'luxon';
import { onDestroy, onMount } from 'svelte';
import Cog from 'svelte-material-icons/Cog.svelte';
import type { PageData } from './$types';
import { DateTime, Duration } from 'luxon';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
export let data: PageData;
@ -29,12 +32,12 @@
});
onDestroy(() => {
if (abortController) {
abortController.abort();
}
abortController?.abort();
assetViewingStore.showAssetViewer(false);
});
$: $featureFlags.map || goto(AppRoute.PHOTOS);
async function loadMapMarkers() {
if (abortController) {
abortController.abort();
@ -98,70 +101,72 @@
}
</script>
<UserPageLayout user={data.user} title={data.meta.title}>
<div class="isolate h-full w-full">
{#if leaflet}
{@const { Map, TileLayer, AssetMarkerCluster, Control } = leaflet}
<Map
center={[30, 0]}
zoom={3}
allowDarkMode={$mapSettings.allowDarkMode}
options={{
maxBounds: [
[-90, -180],
[90, 180],
],
minZoom: 2,
}}
>
<TileLayer
urlTemplate={'https://tile.openstreetmap.org/{z}/{x}/{y}.png'}
{#if $featureFlags.loaded && $featureFlags.map}
<UserPageLayout user={data.user} title={data.meta.title}>
<div class="isolate h-full w-full">
{#if leaflet}
{@const { Map, TileLayer, AssetMarkerCluster, Control } = leaflet}
<Map
center={[30, 0]}
zoom={3}
allowDarkMode={$mapSettings.allowDarkMode}
options={{
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxBounds: [
[-90, -180],
[90, 180],
],
minZoom: 2,
}}
/>
<AssetMarkerCluster
markers={mapMarkers}
on:view={({ detail }) => onViewAssets(detail.assetIds, detail.activeAssetIndex)}
/>
<Control>
<button
class="flex h-8 w-8 items-center justify-center rounded-sm border-2 border-black/20 bg-white font-bold text-black/70 hover:bg-gray-50 focus:bg-gray-50"
title="Open map settings"
on:click={() => (showSettingsModal = true)}
>
<Cog size="100%" class="p-1" />
</button>
</Control>
</Map>
{/if}
</div>
</UserPageLayout>
>
<TileLayer
urlTemplate={$serverConfig.mapTileUrl}
options={{
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}}
/>
<AssetMarkerCluster
markers={mapMarkers}
on:view={({ detail }) => onViewAssets(detail.assetIds, detail.activeAssetIndex)}
/>
<Control>
<button
class="flex h-8 w-8 items-center justify-center rounded-sm border-2 border-black/20 bg-white font-bold text-black/70 hover:bg-gray-50 focus:bg-gray-50"
title="Open map settings"
on:click={() => (showSettingsModal = true)}
>
<Cog size="100%" class="p-1" />
</button>
</Control>
</Map>
{/if}
</div>
</UserPageLayout>
<Portal target="body">
{#if $showAssetViewer}
<AssetViewer
asset={$viewingAsset}
showNavigation={viewingAssets.length > 1}
on:next={navigateNext}
on:previous={navigatePrevious}
on:close={() => assetViewingStore.showAssetViewer(false)}
<Portal target="body">
{#if $showAssetViewer}
<AssetViewer
asset={$viewingAsset}
showNavigation={viewingAssets.length > 1}
on:next={navigateNext}
on:previous={navigatePrevious}
on:close={() => assetViewingStore.showAssetViewer(false)}
/>
{/if}
</Portal>
{#if showSettingsModal}
<MapSettingsModal
settings={{ ...$mapSettings }}
on:close={() => (showSettingsModal = false)}
on:save={async ({ detail }) => {
const shouldUpdate = !isEqual(omit(detail, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
showSettingsModal = false;
$mapSettings = detail;
if (shouldUpdate) {
mapMarkers = await loadMapMarkers();
}
}}
/>
{/if}
</Portal>
{#if showSettingsModal}
<MapSettingsModal
settings={{ ...$mapSettings }}
on:close={() => (showSettingsModal = false)}
on:save={async ({ detail }) => {
const shouldUpdate = !isEqual(omit(detail, 'allowDarkMode'), omit($mapSettings, 'allowDarkMode'));
showSettingsModal = false;
$mapSettings = detail;
if (shouldUpdate) {
mapMarkers = await loadMapMarkers();
}
}}
/>
{/if}

View File

@ -14,7 +14,7 @@
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
import FaviconHeader from '$lib/components/shared-components/favicon-header.svelte';
import { onMount } from 'svelte';
import { loadFeatureFlags } from '$lib/stores/feature-flags.store';
import { loadConfig } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { api } from '@api';
@ -37,9 +37,9 @@
onMount(async () => {
try {
await loadFeatureFlags();
await loadConfig();
} catch (error) {
handleError(error, 'Unable to load feature flags');
handleError(error, 'Unable to connect to server');
}
});

View File

@ -3,6 +3,7 @@
import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte';
import MachineLearningSettings from '$lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte';
import MapSettings from '$lib/components/admin-page/settings/map-settings/map-settings.svelte';
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte';
import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
@ -11,7 +12,7 @@
import Button from '$lib/components/elements/buttons/button.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { downloadManager } from '$lib/stores/download';
import { featureFlags } from '$lib/stores/feature-flags.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { downloadBlob } from '$lib/utils/asset-utils';
import { SystemConfigDto, api, copyToClipboard } from '@api';
import Alert from 'svelte-material-icons/Alert.svelte';
@ -57,20 +58,6 @@
<span class="pl-2">Export as JSON</span>
</Button>
</div>
<SettingAccordion title="Thumbnail Settings" subtitle="Manage the resolution of thumbnail sizes">
<ThumbnailSettings disabled={$featureFlags.configFile} thumbnailConfig={configs.thumbnail} />
</SettingAccordion>
<SettingAccordion
title="Video Transcoding Settings"
subtitle="Manage the resolution and encoding information of the video files"
>
<FFmpegSettings disabled={$featureFlags.configFile} ffmpegConfig={configs.ffmpeg} />
</SettingAccordion>
<SettingAccordion title="Machine Learning Settings" subtitle="Manage model settings">
<MachineLearningSettings disabled={$featureFlags.configFile} machineLearningConfig={configs.machineLearning} />
</SettingAccordion>
<SettingAccordion
title="Job Settings"
@ -80,14 +67,22 @@
<JobSettings disabled={$featureFlags.configFile} jobConfig={configs.job} />
</SettingAccordion>
<SettingAccordion title="Password Authentication" subtitle="Manage login with password settings">
<PasswordLoginSettings disabled={$featureFlags.configFile} passwordLoginConfig={configs.passwordLogin} />
<SettingAccordion title="Machine Learning Settings" subtitle="Manage model settings">
<MachineLearningSettings disabled={$featureFlags.configFile} machineLearningConfig={configs.machineLearning} />
</SettingAccordion>
<SettingAccordion title="Map Settings" subtitle="Manage map settings">
<MapSettings disabled={$featureFlags.configFile} mapConfig={configs.map} />
</SettingAccordion>
<SettingAccordion title="OAuth Authentication" subtitle="Manage the login with OAuth settings">
<OAuthSettings disabled={$featureFlags.configFile} oauthConfig={configs.oauth} />
</SettingAccordion>
<SettingAccordion title="Password Authentication" subtitle="Manage login with password settings">
<PasswordLoginSettings disabled={$featureFlags.configFile} passwordLoginConfig={configs.passwordLogin} />
</SettingAccordion>
<SettingAccordion
title="Storage Template"
subtitle="Manage the folder structure and file name of the upload asset"
@ -99,5 +94,16 @@
user={data.user}
/>
</SettingAccordion>
<SettingAccordion title="Thumbnail Settings" subtitle="Manage the resolution of thumbnail sizes">
<ThumbnailSettings disabled={$featureFlags.configFile} thumbnailConfig={configs.thumbnail} />
</SettingAccordion>
<SettingAccordion
title="Video Transcoding Settings"
subtitle="Manage the resolution and encoding information of the video files"
>
<FFmpegSettings disabled={$featureFlags.configFile} ffmpegConfig={configs.ffmpeg} />
</SettingAccordion>
{/await}
</section>

View File

@ -3,18 +3,17 @@
import LoginForm from '$lib/components/forms/login-form.svelte';
import FullscreenContainer from '$lib/components/shared-components/fullscreen-container.svelte';
import { AppRoute } from '$lib/constants';
import { loginPageMessage } from '$lib/constants';
import { featureFlags } from '$lib/stores/feature-flags.store';
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
import type { PageData } from './$types';
export let data: PageData;
</script>
{#if $featureFlags.loaded}
<FullscreenContainer title={data.meta.title} showMessage={!!loginPageMessage}>
<FullscreenContainer title={data.meta.title} showMessage={!!$serverConfig.loginPageMessage}>
<p slot="message">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html loginPageMessage}
{@html $serverConfig.loginPageMessage}
</p>
<LoginForm