mirror of
https://github.com/immich-app/immich.git
synced 2025-01-16 00:36:47 +01:00
WIP
This commit is contained in:
parent
14b1425e98
commit
3fd5a32a7d
22 changed files with 665 additions and 2 deletions
|
@ -14,6 +14,7 @@ import { MemoryController } from 'src/controllers/memory.controller';
|
||||||
import { OAuthController } from 'src/controllers/oauth.controller';
|
import { OAuthController } from 'src/controllers/oauth.controller';
|
||||||
import { PartnerController } from 'src/controllers/partner.controller';
|
import { PartnerController } from 'src/controllers/partner.controller';
|
||||||
import { PersonController } from 'src/controllers/person.controller';
|
import { PersonController } from 'src/controllers/person.controller';
|
||||||
|
import { PluginController } from 'src/controllers/plugin.controller';
|
||||||
import { SearchController } from 'src/controllers/search.controller';
|
import { SearchController } from 'src/controllers/search.controller';
|
||||||
import { ServerInfoController } from 'src/controllers/server-info.controller';
|
import { ServerInfoController } from 'src/controllers/server-info.controller';
|
||||||
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
||||||
|
@ -41,6 +42,7 @@ export const controllers = [
|
||||||
MemoryController,
|
MemoryController,
|
||||||
OAuthController,
|
OAuthController,
|
||||||
PartnerController,
|
PartnerController,
|
||||||
|
PluginController,
|
||||||
SearchController,
|
SearchController,
|
||||||
ServerInfoController,
|
ServerInfoController,
|
||||||
SharedLinkController,
|
SharedLinkController,
|
||||||
|
|
49
server/src/controllers/plugin.controller.ts
Normal file
49
server/src/controllers/plugin.controller.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { PluginImportDto, PluginResponseDto, PluginUpdateDto, SearchPluginDto } from 'src/dtos/plugin.dto';
|
||||||
|
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||||
|
import { PluginService } from 'src/services/plugin.service';
|
||||||
|
import { UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
|
@ApiTags('Plugin')
|
||||||
|
@Controller('plugins')
|
||||||
|
@Authenticated()
|
||||||
|
export class PluginController {
|
||||||
|
constructor(private service: PluginService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
searchPlugins(@Auth() auth: AuthDto, @Query() dto: SearchPluginDto): Promise<PluginResponseDto[]> {
|
||||||
|
return this.service.search(auth, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
importPlugin(@Auth() auth: AuthDto, @Body() dto: PluginImportDto): Promise<PluginResponseDto> {
|
||||||
|
return this.service.import(auth, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
updatePlugin(
|
||||||
|
@Auth() auth: AuthDto,
|
||||||
|
@Param() { id }: UUIDParamDto,
|
||||||
|
@Body() dto: PluginUpdateDto,
|
||||||
|
): Promise<PluginResponseDto> {
|
||||||
|
return this.service.update(auth, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/install')
|
||||||
|
installPlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
|
||||||
|
return this.service.install(auth, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/uninstall')
|
||||||
|
uninstallPlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
|
||||||
|
return this.service.uninstall(auth, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
deletePlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||||
|
return this.service.delete(auth, id);
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,6 +43,13 @@ export enum Permission {
|
||||||
PERSON_CREATE = 'person.create',
|
PERSON_CREATE = 'person.create',
|
||||||
PERSON_REASSIGN = 'person.reassign',
|
PERSON_REASSIGN = 'person.reassign',
|
||||||
|
|
||||||
|
PLUGIN_READ = 'plugin.read',
|
||||||
|
PLUGIN_WRITE = 'plugin.write',
|
||||||
|
PLUGIN_DELETE = 'plugin.delete',
|
||||||
|
PLUGIN_ADMIN = 'plugin.admin',
|
||||||
|
PLUGIN_INSTALL = 'plugin.install',
|
||||||
|
PLUGIN_UNINSTALL = 'plugin.uninstall',
|
||||||
|
|
||||||
PARTNER_UPDATE = 'partner.update',
|
PARTNER_UPDATE = 'partner.update',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
58
server/src/dtos/plugin.dto.ts
Normal file
58
server/src/dtos/plugin.dto.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsBoolean, IsString } from 'class-validator';
|
||||||
|
import { PluginEntity } from 'src/entities/plugin.entity';
|
||||||
|
import { Optional, ValidateBoolean } from 'src/validation';
|
||||||
|
|
||||||
|
export class SearchPluginDto {
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isEnabled?: boolean;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isOfficial?: boolean;
|
||||||
|
|
||||||
|
@ValidateBoolean({ optional: true })
|
||||||
|
isInstalled?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Optional()
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginImportDto {
|
||||||
|
url!: string;
|
||||||
|
install!: boolean;
|
||||||
|
isEnabled!: boolean;
|
||||||
|
isOfficial!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginUpdateDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isEnabled!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PluginResponseDto {
|
||||||
|
id!: string;
|
||||||
|
createdAt!: Date;
|
||||||
|
updatedAt!: Date;
|
||||||
|
packageId!: string;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
version!: number;
|
||||||
|
name!: string;
|
||||||
|
description!: string;
|
||||||
|
isEnabled!: boolean;
|
||||||
|
isInstalled!: boolean;
|
||||||
|
isTrusted!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapPlugin = (plugin: PluginEntity): PluginResponseDto => ({
|
||||||
|
id: plugin.id,
|
||||||
|
createdAt: plugin.createdAt,
|
||||||
|
updatedAt: plugin.updatedAt,
|
||||||
|
packageId: plugin.packageId,
|
||||||
|
version: plugin.version,
|
||||||
|
name: plugin.name,
|
||||||
|
description: plugin.description,
|
||||||
|
isEnabled: plugin.isEnabled,
|
||||||
|
isInstalled: plugin.isInstalled,
|
||||||
|
isTrusted: plugin.isTrusted,
|
||||||
|
});
|
|
@ -13,6 +13,7 @@ import { MemoryEntity } from 'src/entities/memory.entity';
|
||||||
import { MoveEntity } from 'src/entities/move.entity';
|
import { MoveEntity } from 'src/entities/move.entity';
|
||||||
import { PartnerEntity } from 'src/entities/partner.entity';
|
import { PartnerEntity } from 'src/entities/partner.entity';
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
|
import { PluginEntity } from 'src/entities/plugin.entity';
|
||||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||||
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
|
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
|
||||||
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
||||||
|
@ -37,6 +38,7 @@ export const entities = [
|
||||||
MoveEntity,
|
MoveEntity,
|
||||||
PartnerEntity,
|
PartnerEntity,
|
||||||
PersonEntity,
|
PersonEntity,
|
||||||
|
PluginEntity,
|
||||||
SharedLinkEntity,
|
SharedLinkEntity,
|
||||||
SmartInfoEntity,
|
SmartInfoEntity,
|
||||||
SmartSearchEntity,
|
SmartSearchEntity,
|
||||||
|
|
40
server/src/entities/plugin.entity.ts
Normal file
40
server/src/entities/plugin.entity.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('plugins')
|
||||||
|
export class PluginEntity {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn({ type: 'timestamptz', nullable: true })
|
||||||
|
deletedAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
packageId!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
version!: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true })
|
||||||
|
isEnabled!: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
isInstalled!: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
isTrusted!: boolean;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
installPath!: string | null;
|
||||||
|
}
|
|
@ -89,6 +89,9 @@ export enum JobName {
|
||||||
SIDECAR_DISCOVERY = 'sidecar-discovery',
|
SIDECAR_DISCOVERY = 'sidecar-discovery',
|
||||||
SIDECAR_SYNC = 'sidecar-sync',
|
SIDECAR_SYNC = 'sidecar-sync',
|
||||||
SIDECAR_WRITE = 'sidecar-write',
|
SIDECAR_WRITE = 'sidecar-write',
|
||||||
|
|
||||||
|
// workflows
|
||||||
|
WORKFLOW_TRIGGER = 'workflow-trigger',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
||||||
|
@ -135,6 +138,17 @@ export interface IDeferrableJob extends IEntityJob {
|
||||||
deferred?: boolean;
|
deferred?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum WorkflowTriggerType {
|
||||||
|
ASSET_UPLOAD = 'asset.upload',
|
||||||
|
ASSET_UPDATE = 'asset.update',
|
||||||
|
ASSET_TRASH = 'asset.trash',
|
||||||
|
ASSET_DELETE = 'asset.delete',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IWorkflowTriggerJob =
|
||||||
|
| { type: WorkflowTriggerType.ASSET_UPLOAD; data: { assetId: string } }
|
||||||
|
| { type: WorkflowTriggerType.ASSET_UPDATE; data: { asset2Id: string } };
|
||||||
|
|
||||||
export interface JobCounts {
|
export interface JobCounts {
|
||||||
active: number;
|
active: number;
|
||||||
completed: number;
|
completed: number;
|
||||||
|
@ -216,7 +230,8 @@ export type JobItem =
|
||||||
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
|
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
|
||||||
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
|
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
|
||||||
| { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob }
|
| { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob }
|
||||||
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob };
|
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
|
||||||
|
| { name: JobName.WORKFLOW_TRIGGER; data: IWorkflowTriggerJob };
|
||||||
|
|
||||||
export enum JobStatus {
|
export enum JobStatus {
|
||||||
SUCCESS = 'success',
|
SUCCESS = 'success',
|
||||||
|
|
116
server/src/interfaces/plugin.interface.ts
Normal file
116
server/src/interfaces/plugin.interface.ts
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
import { PluginEntity } from 'src/entities/plugin.entity';
|
||||||
|
|
||||||
|
export const IPluginRepository = 'IPluginRepository';
|
||||||
|
|
||||||
|
export interface PluginSearchOptions {
|
||||||
|
id?: string;
|
||||||
|
namespace?: string;
|
||||||
|
version?: number;
|
||||||
|
name?: string;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
isInstalled?: boolean;
|
||||||
|
isOfficial?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPluginRepository {
|
||||||
|
search(options: PluginSearchOptions): Promise<PluginEntity[]>;
|
||||||
|
create(dto: Partial<PluginEntity>): Promise<PluginEntity>;
|
||||||
|
get(id: string): Promise<PluginEntity | null>;
|
||||||
|
update(dto: Partial<PluginEntity>): Promise<PluginEntity>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
|
||||||
|
download(url: string, downloadPath: string): Promise<void>;
|
||||||
|
load(pluginPath: string): Promise<PluginLike>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginFactory = {
|
||||||
|
register: () => MaybePromise<Plugin>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginLike = Plugin | PluginFactory | { default: Plugin } | { plugin: Plugin };
|
||||||
|
|
||||||
|
export interface Plugin<T extends PluginConfig | undefined = undefined> {
|
||||||
|
version: 1;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
actions: PluginAction<T>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginAction<T extends PluginConfig | undefined = undefined> = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
events?: EventType[];
|
||||||
|
config?: T;
|
||||||
|
} & (
|
||||||
|
| { type: ActionType.ASSET; onAction: OnAction<T, AssetDto> }
|
||||||
|
| { type: ActionType.ALBUM; onAction: OnAction<T, AlbumDto> }
|
||||||
|
| { type: ActionType.ALBUM_ASSET; onAction: OnAction<T, { asset: AssetDto; album: AlbumDto }> }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type OnAction<T extends PluginConfig | undefined, D = PluginActionData> = T extends undefined
|
||||||
|
? (ctx: PluginContext, data: D) => MaybePromise<void>
|
||||||
|
: (ctx: PluginContext, data: D, config: InferConfig<T>) => MaybePromise<void>;
|
||||||
|
|
||||||
|
export interface PluginContext {
|
||||||
|
updateAsset: (asset: { id: string; isArchived: boolean }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginActionData = { data: { asset?: AssetDto; album?: AlbumDto } } & (
|
||||||
|
| { type: EventType.ASSET_UPLOAD; data: { asset: AssetDto } }
|
||||||
|
| { type: EventType.ASSET_UPDATE; data: { asset: AssetDto } }
|
||||||
|
| { type: EventType.ASSET_TRASH; data: { asset: AssetDto } }
|
||||||
|
| { type: EventType.ASSET_DELETE; data: { asset: AssetDto } }
|
||||||
|
| { type: EventType.ALBUM_CREATE; data: { album: AlbumDto } }
|
||||||
|
| { type: EventType.ALBUM_UPDATE; data: { album: AlbumDto } }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PluginConfig = Record<string, ConfigItem>;
|
||||||
|
|
||||||
|
export type ConfigItem = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
required?: boolean;
|
||||||
|
} & { [K in Types]: { type: K; default?: InferType<K> } }[Types];
|
||||||
|
|
||||||
|
export type InferType<T extends Types> = T extends 'string'
|
||||||
|
? string
|
||||||
|
: T extends 'date'
|
||||||
|
? Date
|
||||||
|
: T extends 'number'
|
||||||
|
? number
|
||||||
|
: T extends 'boolean'
|
||||||
|
? boolean
|
||||||
|
: never;
|
||||||
|
|
||||||
|
type Types = 'string' | 'boolean' | 'number' | 'date';
|
||||||
|
type MaybePromise<T = void> = Promise<T> | T;
|
||||||
|
type IfRequired<T extends ConfigItem, Type> = T['required'] extends true ? Type : Type | undefined;
|
||||||
|
type InferConfig<T> = T extends PluginConfig
|
||||||
|
? {
|
||||||
|
[K in keyof T]: IfRequired<T[K], InferType<T[K]['type']>>;
|
||||||
|
}
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export enum ActionType {
|
||||||
|
ASSET = 'asset',
|
||||||
|
ALBUM = 'album',
|
||||||
|
ALBUM_ASSET = 'album-asset',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EventType {
|
||||||
|
ASSET_UPLOAD = 'asset.upload',
|
||||||
|
ASSET_UPDATE = 'asset.update',
|
||||||
|
ASSET_TRASH = 'asset.trash',
|
||||||
|
ASSET_DELETE = 'asset.delete',
|
||||||
|
ASSET_ARCHIVE = 'asset.archvie',
|
||||||
|
ASSET_UNARCHIVE = 'asset.unarchive',
|
||||||
|
|
||||||
|
ALBUM_CREATE = 'album.create',
|
||||||
|
ALBUM_UPDATE = 'album.update',
|
||||||
|
ALBUM_DELETE = 'album.delete',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssetDto = { id: string; type: 'asset' };
|
||||||
|
export type AlbumDto = { id: string; type: 'album' };
|
55
server/src/plugins/asset.plugin.ts
Normal file
55
server/src/plugins/asset.plugin.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { ActionType, AssetDto, Plugin, PluginContext } from 'src/interfaces/plugin.interface';
|
||||||
|
|
||||||
|
const onAsset = async (ctx: PluginContext, asset: AssetDto) => {
|
||||||
|
await ctx.updateAsset({ id: asset.id, isArchived: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const plugin: Plugin = {
|
||||||
|
version: 1,
|
||||||
|
id: 'immich-plugin-asset',
|
||||||
|
name: 'Asset Plugin',
|
||||||
|
description: 'Immich asset plugin',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'asset.favorite',
|
||||||
|
name: '',
|
||||||
|
type: ActionType.ASSET,
|
||||||
|
description: '',
|
||||||
|
onAction: async (ctx, asset) => {
|
||||||
|
await ctx.updateAsset({ id: asset.id, isArchived: false });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'asset.unfavorite',
|
||||||
|
name: '',
|
||||||
|
type: ActionType.ASSET,
|
||||||
|
description: '',
|
||||||
|
onAction: () => {
|
||||||
|
console.log('Unfavorite');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'asset.action',
|
||||||
|
name: '',
|
||||||
|
type: ActionType.ASSET,
|
||||||
|
description: '',
|
||||||
|
onAction: (ctx, asset) => onAsset(ctx, asset),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'album-asset.action',
|
||||||
|
name: '',
|
||||||
|
type: ActionType.ALBUM_ASSET,
|
||||||
|
description: '',
|
||||||
|
onAction: (ctx, { asset }) => onAsset(ctx, asset),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'asset.unarchive',
|
||||||
|
name: '',
|
||||||
|
type: ActionType.ASSET,
|
||||||
|
description: '',
|
||||||
|
onAction: () => {
|
||||||
|
console.log('Unarchive');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
|
@ -20,6 +20,7 @@ import { IMetricRepository } from 'src/interfaces/metric.interface';
|
||||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||||
|
import { IPluginRepository } from 'src/interfaces/plugin.interface';
|
||||||
import { ISearchRepository } from 'src/interfaces/search.interface';
|
import { ISearchRepository } from 'src/interfaces/search.interface';
|
||||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||||
|
@ -51,6 +52,7 @@ import { MetricRepository } from 'src/repositories/metric.repository';
|
||||||
import { MoveRepository } from 'src/repositories/move.repository';
|
import { MoveRepository } from 'src/repositories/move.repository';
|
||||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||||
import { PersonRepository } from 'src/repositories/person.repository';
|
import { PersonRepository } from 'src/repositories/person.repository';
|
||||||
|
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||||
import { SearchRepository } from 'src/repositories/search.repository';
|
import { SearchRepository } from 'src/repositories/search.repository';
|
||||||
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
|
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
|
||||||
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
|
||||||
|
@ -83,6 +85,7 @@ export const repositories = [
|
||||||
{ provide: IMoveRepository, useClass: MoveRepository },
|
{ provide: IMoveRepository, useClass: MoveRepository },
|
||||||
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
{ provide: IPartnerRepository, useClass: PartnerRepository },
|
||||||
{ provide: IPersonRepository, useClass: PersonRepository },
|
{ provide: IPersonRepository, useClass: PersonRepository },
|
||||||
|
{ provide: IPluginRepository, useClass: PluginRepository },
|
||||||
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
|
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
|
||||||
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
|
||||||
{ provide: ISearchRepository, useClass: SearchRepository },
|
{ provide: ISearchRepository, useClass: SearchRepository },
|
||||||
|
|
|
@ -77,6 +77,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||||
[JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY,
|
[JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY,
|
||||||
[JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY,
|
[JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY,
|
||||||
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
|
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
|
||||||
|
|
||||||
|
// workflows
|
||||||
|
[JobName.WORKFLOW_TRIGGER]: QueueName.BACKGROUND_TASK,
|
||||||
};
|
};
|
||||||
|
|
||||||
@Instrumentation()
|
@Instrumentation()
|
||||||
|
|
56
server/src/repositories/plugin.repository.ts
Normal file
56
server/src/repositories/plugin.repository.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { PluginEntity } from 'src/entities/plugin.entity';
|
||||||
|
import { IPluginRepository, PluginLike, PluginSearchOptions } from 'src/interfaces/plugin.interface';
|
||||||
|
import { ImmichLogger } from 'src/utils/logger';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PluginRepository implements IPluginRepository {
|
||||||
|
private logger = new ImmichLogger(PluginRepository.name);
|
||||||
|
constructor(@InjectRepository(PluginEntity) private repository: Repository<PluginEntity>) {}
|
||||||
|
|
||||||
|
search(options: PluginSearchOptions): Promise<PluginEntity[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: {
|
||||||
|
id: options.id,
|
||||||
|
packageId: options.namespace,
|
||||||
|
version: options.version,
|
||||||
|
name: options.name,
|
||||||
|
isEnabled: options.isEnabled,
|
||||||
|
isInstalled: options.isInstalled,
|
||||||
|
isTrusted: options.isOfficial,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create(dto: Partial<PluginEntity>): Promise<PluginEntity> {
|
||||||
|
return this.repository.save(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): Promise<PluginEntity | null> {
|
||||||
|
return this.repository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
update(dto: Partial<PluginEntity>): Promise<PluginEntity> {
|
||||||
|
return this.repository.save(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.repository.delete({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(url: string, downloadPath: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { json } = await fetch(url);
|
||||||
|
await writeFile(downloadPath, await json());
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error downloading the plugin from ${url}. ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load(pluginPath: string): Promise<PluginLike> {
|
||||||
|
return import(pluginPath);
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,6 +16,7 @@ import { MetadataService } from 'src/services/metadata.service';
|
||||||
import { MicroservicesService } from 'src/services/microservices.service';
|
import { MicroservicesService } from 'src/services/microservices.service';
|
||||||
import { PartnerService } from 'src/services/partner.service';
|
import { PartnerService } from 'src/services/partner.service';
|
||||||
import { PersonService } from 'src/services/person.service';
|
import { PersonService } from 'src/services/person.service';
|
||||||
|
import { PluginService } from 'src/services/plugin.service';
|
||||||
import { SearchService } from 'src/services/search.service';
|
import { SearchService } from 'src/services/search.service';
|
||||||
import { ServerInfoService } from 'src/services/server-info.service';
|
import { ServerInfoService } from 'src/services/server-info.service';
|
||||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||||
|
@ -28,6 +29,7 @@ import { TagService } from 'src/services/tag.service';
|
||||||
import { TimelineService } from 'src/services/timeline.service';
|
import { TimelineService } from 'src/services/timeline.service';
|
||||||
import { TrashService } from 'src/services/trash.service';
|
import { TrashService } from 'src/services/trash.service';
|
||||||
import { UserService } from 'src/services/user.service';
|
import { UserService } from 'src/services/user.service';
|
||||||
|
import { WorkflowService } from 'src/services/workflow.service';
|
||||||
|
|
||||||
export const services = [
|
export const services = [
|
||||||
ApiService,
|
ApiService,
|
||||||
|
@ -48,6 +50,7 @@ export const services = [
|
||||||
MetadataService,
|
MetadataService,
|
||||||
PartnerService,
|
PartnerService,
|
||||||
PersonService,
|
PersonService,
|
||||||
|
PluginService,
|
||||||
SearchService,
|
SearchService,
|
||||||
ServerInfoService,
|
ServerInfoService,
|
||||||
SharedLinkService,
|
SharedLinkService,
|
||||||
|
@ -60,4 +63,5 @@ export const services = [
|
||||||
TimelineService,
|
TimelineService,
|
||||||
TrashService,
|
TrashService,
|
||||||
UserService,
|
UserService,
|
||||||
|
WorkflowService,
|
||||||
];
|
];
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
JobStatus,
|
JobStatus,
|
||||||
QueueCleanType,
|
QueueCleanType,
|
||||||
QueueName,
|
QueueName,
|
||||||
|
WorkflowTriggerType,
|
||||||
} from 'src/interfaces/job.interface';
|
} from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { IMetricRepository } from 'src/interfaces/metric.interface';
|
import { IMetricRepository } from 'src/interfaces/metric.interface';
|
||||||
|
@ -294,6 +295,13 @@ export class JobService {
|
||||||
if (asset && asset.isVisible) {
|
if (asset && asset.isVisible) {
|
||||||
this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asset) {
|
||||||
|
await this.jobRepository.queue({
|
||||||
|
name: JobName.WORKFLOW_TRIGGER,
|
||||||
|
data: { type: WorkflowTriggerType.ASSET_UPLOAD, data: { assetId: asset.id } },
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||||
import { StorageService } from 'src/services/storage.service';
|
import { StorageService } from 'src/services/storage.service';
|
||||||
import { SystemConfigService } from 'src/services/system-config.service';
|
import { SystemConfigService } from 'src/services/system-config.service';
|
||||||
import { UserService } from 'src/services/user.service';
|
import { UserService } from 'src/services/user.service';
|
||||||
|
import { WorkflowService } from 'src/services/workflow.service';
|
||||||
import { otelSDK } from 'src/utils/instrumentation';
|
import { otelSDK } from 'src/utils/instrumentation';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
@ -31,6 +32,7 @@ export class MicroservicesService {
|
||||||
private storageService: StorageService,
|
private storageService: StorageService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private databaseService: DatabaseService,
|
private databaseService: DatabaseService,
|
||||||
|
private workflowService: WorkflowService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
@ -77,6 +79,7 @@ export class MicroservicesService {
|
||||||
[JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data),
|
[JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data),
|
||||||
[JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data),
|
[JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data),
|
||||||
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
|
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
|
||||||
|
[JobName.WORKFLOW_TRIGGER]: (data) => this.workflowService.handleTrigger(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.metadataService.init();
|
await this.metadataService.init();
|
||||||
|
|
108
server/src/services/plugin.service.ts
Normal file
108
server/src/services/plugin.service.ts
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
import { BadRequestException, Inject } from '@nestjs/common';
|
||||||
|
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||||
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { PluginImportDto, PluginUpdateDto, SearchPluginDto, mapPlugin } from 'src/dtos/plugin.dto';
|
||||||
|
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||||
|
import { IPluginRepository, Plugin, PluginFactory } from 'src/interfaces/plugin.interface';
|
||||||
|
|
||||||
|
export class PluginService {
|
||||||
|
private access: AccessCore;
|
||||||
|
constructor(
|
||||||
|
@Inject(IPluginRepository) private pluginRepository: IPluginRepository,
|
||||||
|
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||||
|
) {
|
||||||
|
this.access = AccessCore.create(accessRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
search(auth: AuthDto, dto: SearchPluginDto) {
|
||||||
|
// await this.access.requirePermission(authUser, Permission.PLUGIN_READ);
|
||||||
|
return this.pluginRepository.search(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async import(auth: AuthDto, dto: PluginImportDto) {
|
||||||
|
// await this.access.requirePermission(authUser, Permission.PLUGIN_ADMIN);
|
||||||
|
if (dto.url.startsWith('http')) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginPath = '/path/to/plugin';
|
||||||
|
await this.pluginRepository.download(dto.url, pluginPath);
|
||||||
|
const { version, id, name, description } = await this.load(pluginPath);
|
||||||
|
|
||||||
|
const response = await this.pluginRepository.create({
|
||||||
|
version,
|
||||||
|
packageId: id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
isInstalled: false,
|
||||||
|
isEnabled: dto.isEnabled,
|
||||||
|
isTrusted: false,
|
||||||
|
installPath: pluginPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapPlugin(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async install(auth: AuthDto, id: string) {
|
||||||
|
await this.access.requirePermission(auth, Permission.PLUGIN_INSTALL, id);
|
||||||
|
// TODO
|
||||||
|
return this.pluginRepository.update({ id, isInstalled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstall(auth: AuthDto, id: string) {
|
||||||
|
await this.access.requirePermission(auth, Permission.PLUGIN_UNINSTALL, id);
|
||||||
|
// TODO
|
||||||
|
return this.pluginRepository.update({ id, isInstalled: false, installPath: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(auth: AuthDto, id: string, dto: PluginUpdateDto) {
|
||||||
|
await this.access.requirePermission(auth, Permission.PLUGIN_WRITE, id);
|
||||||
|
return this.pluginRepository.update({
|
||||||
|
id,
|
||||||
|
isEnabled: dto.isEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(auth: AuthDto, id: string) {
|
||||||
|
await this.access.requirePermission(auth, Permission.PLUGIN_WRITE, id);
|
||||||
|
await this.findOrFail(id);
|
||||||
|
await this.pluginRepository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOrFail(id: string) {
|
||||||
|
const plugin = await this.pluginRepository.get(id);
|
||||||
|
if (!plugin) {
|
||||||
|
throw new BadRequestException('Plugin not found');
|
||||||
|
}
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO security implications
|
||||||
|
private async load(pluginPath: string): Promise<Plugin> {
|
||||||
|
const pluginLike = await this.pluginRepository.load(pluginPath);
|
||||||
|
let plugin: Plugin | undefined;
|
||||||
|
|
||||||
|
const pluginModule = pluginLike as { default: Plugin };
|
||||||
|
if (pluginModule.default) {
|
||||||
|
plugin = pluginModule.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginExport = pluginLike as { plugin: Plugin };
|
||||||
|
if (pluginExport.plugin) {
|
||||||
|
plugin = pluginExport.plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginFactory = pluginLike as PluginFactory;
|
||||||
|
if (pluginFactory) {
|
||||||
|
plugin = await pluginFactory.register();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO use class-validator
|
||||||
|
const isPlugin = plugin && !!plugin.version && Array.isArray(plugin.actions);
|
||||||
|
if (isPlugin) {
|
||||||
|
return plugin as Plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unable to load plugin');
|
||||||
|
}
|
||||||
|
}
|
50
server/src/services/workflow.service.ts
Normal file
50
server/src/services/workflow.service.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { IWorkflowTriggerJob, JobStatus, WorkflowTriggerType } from 'src/interfaces/job.interface';
|
||||||
|
import { IPluginRepository, PluginLike } from 'src/interfaces/plugin.interface';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkflowService {
|
||||||
|
private plugins?: PluginLike[];
|
||||||
|
constructor(@Inject(IPluginRepository) private pluginRepository: IPluginRepository) {}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
const activePlugins = await this.pluginRepository.search({ isEnabled: true });
|
||||||
|
this.plugins = await Promise.all(activePlugins.map(({ installPath }) => this.pluginRepository.load(installPath)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// async register() {
|
||||||
|
// const plugins = ['/src/abc'];
|
||||||
|
// for (const pluginModule of plugins) {
|
||||||
|
// // eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
// try {
|
||||||
|
// const plugin: Plugin = ;
|
||||||
|
// const actions = await plugin.register();
|
||||||
|
// for (const action of actions) {
|
||||||
|
// this.actions[action.id] = action;
|
||||||
|
// }
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error(`Unable to load module: ${pluginModule}`, error);
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
async handleTrigger(data: IWorkflowTriggerJob): Promise<JobStatus> {
|
||||||
|
if (data.type === WorkflowTriggerType.ASSET_UPLOAD) {
|
||||||
|
// const stuff = data.data;
|
||||||
|
return JobStatus.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
// const workflows = this.getWorkflows();
|
||||||
|
// const plugins = await this.getPlugins();
|
||||||
|
|
||||||
|
// for (const plugin of plugins) {
|
||||||
|
// if (workflow.trigger === data.type) {
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
return JobStatus.SKIPPED;
|
||||||
|
}
|
||||||
|
}
|
12
server/src/utils/plugin.ts
Normal file
12
server/src/utils/plugin.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { AssetDto, EventType, OnAction, PluginConfig } from 'src/interfaces/plugin.interface';
|
||||||
|
|
||||||
|
export const createPluginAction = <T extends PluginConfig | undefined = undefined>(options: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
events?: EventType[];
|
||||||
|
config?: T;
|
||||||
|
}) => ({
|
||||||
|
addHandler: (onAction: OnAction<T>) => ({ ...options, onAction }),
|
||||||
|
onAsset: (onAction: OnAction<T, AssetDto>) => ({ ...options, onAction }),
|
||||||
|
});
|
|
@ -3,7 +3,7 @@
|
||||||
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
|
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
|
||||||
import StatusBox from '$lib/components/shared-components/status-box.svelte';
|
import StatusBox from '$lib/components/shared-components/status-box.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js';
|
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiPuzzle, mdiServer, mdiSync, mdiTools } from '@mdi/js';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SideBarSection>
|
<SideBarSection>
|
||||||
|
@ -14,6 +14,7 @@
|
||||||
<SideBarLink title="External Libraries" routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
|
<SideBarLink title="External Libraries" routeId={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
|
||||||
<SideBarLink title="Server Stats" routeId={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
<SideBarLink title="Server Stats" routeId={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||||
<SideBarLink title="Repair" routeId={AppRoute.ADMIN_REPAIR} icon={mdiTools} preloadData={false} />
|
<SideBarLink title="Repair" routeId={AppRoute.ADMIN_REPAIR} icon={mdiTools} preloadData={false} />
|
||||||
|
<SideBarLink title="Plugins" routeId={AppRoute.ADMIN_PLUGINS} icon={mdiPuzzle} preloadData={false} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="mb-6 mt-auto">
|
<div class="mb-6 mt-auto">
|
||||||
|
|
|
@ -16,6 +16,7 @@ export enum AppRoute {
|
||||||
ADMIN_STATS = '/admin/server-status',
|
ADMIN_STATS = '/admin/server-status',
|
||||||
ADMIN_JOBS = '/admin/jobs-status',
|
ADMIN_JOBS = '/admin/jobs-status',
|
||||||
ADMIN_REPAIR = '/admin/repair',
|
ADMIN_REPAIR = '/admin/repair',
|
||||||
|
ADMIN_PLUGINS = '/admin/plugins',
|
||||||
|
|
||||||
ALBUMS = '/albums',
|
ALBUMS = '/albums',
|
||||||
LIBRARIES = '/libraries',
|
LIBRARIES = '/libraries',
|
||||||
|
|
54
web/src/routes/admin/plugins/+page.svelte
Normal file
54
web/src/routes/admin/plugins/+page.svelte
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
|
import { mdiCheckDecagram, mdiWrench } from '@mdi/js';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { range } from 'lodash-es';
|
||||||
|
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
const plugins = range(0, 8).map((index) => ({
|
||||||
|
name: `Plugin-${index}`,
|
||||||
|
description: `Plugin ${index} is awesome because it can do x and even y!`,
|
||||||
|
isEnabled: Math.random() < 0.5,
|
||||||
|
isInstalled: Math.random() < 0.5,
|
||||||
|
isOfficial: Math.random() < 0.5,
|
||||||
|
version: 1,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<UserPageLayout title={data.meta.title} admin>
|
||||||
|
<svelte:fragment slot="sidebar" />
|
||||||
|
<div class="flex justify-end gap-2" slot="buttons">
|
||||||
|
<LinkButton on:click={() => console.log('clicked')}>
|
||||||
|
<div class="flex place-items-center gap-2 text-sm">
|
||||||
|
<Icon path={mdiWrench} size="18" />
|
||||||
|
Test
|
||||||
|
</div>
|
||||||
|
</LinkButton>
|
||||||
|
</div>
|
||||||
|
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
|
{#each plugins as plugin}
|
||||||
|
<section
|
||||||
|
class="flex flex-col gap-4 justify-between dark:bg-immich-dark-gray bg-immich-gray dark:border-0 border-gray-200 border border-solid rounded-2xl p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h1 class="m-0 items-start flex gap-2">
|
||||||
|
{plugin.name}
|
||||||
|
{#if plugin.isOfficial}
|
||||||
|
<Icon path={mdiCheckDecagram} size="18" color="green" />
|
||||||
|
{/if}
|
||||||
|
<div class="place-self-end justify-self-end justify-end self-end">Version {plugin.version}</div>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="m-0 text-sm text-gray-600 dark:text-gray-300">{plugin.description}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex">Is {plugin.isInstalled ? '' : 'not '}installed</div>
|
||||||
|
<SettingSwitch checked={plugin.isEnabled} id={plugin.name} title="Enabled" />
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</UserPageLayout>
|
16
web/src/routes/admin/plugins/+page.ts
Normal file
16
web/src/routes/admin/plugins/+page.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { authenticate } from '$lib/utils/auth';
|
||||||
|
import { getAuditFiles } from '@immich/sdk';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
await authenticate({ admin: true });
|
||||||
|
const { orphans, extras } = await getAuditFiles();
|
||||||
|
|
||||||
|
return {
|
||||||
|
orphans,
|
||||||
|
extras,
|
||||||
|
meta: {
|
||||||
|
title: 'Plugins',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
Loading…
Reference in a new issue