mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
Vendored
+20
@@ -1,5 +1,25 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"svelte.enable-ts-plugin": true,
|
||||
"[typescript]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit",
|
||||
"source.removeUnusedImports": "explicit"
|
||||
}
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit",
|
||||
"source.removeUnusedImports": "explicit"
|
||||
}
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit",
|
||||
"source.removeUnusedImports": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode"
|
||||
},
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "yucca-api-client",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.69",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -7,6 +7,18 @@ async function bootstrap() {
|
||||
OrchestrationApiModule.forRoot({
|
||||
yuccaProductionApi: 'http://localhost:36033',
|
||||
developmentMode: true,
|
||||
immichIntegration: {
|
||||
dataFolders: ['upload', 'profile', 'library', 'backups', 'thumbs', 'encoded-video'],
|
||||
dataPath: '/immich_data',
|
||||
libraries: [
|
||||
{
|
||||
id: 'my-library',
|
||||
name: 'my library',
|
||||
importPaths: ['/immich_library'],
|
||||
exclusionPatterns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -751,6 +751,36 @@
|
||||
}
|
||||
},
|
||||
"/api/yucca/logs/{id}": {
|
||||
"get": {
|
||||
"operationId": "getRun",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RunResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"RunHistory"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/yucca/logs/{id}/stream": {
|
||||
"get": {
|
||||
"operationId": "logStreamSse",
|
||||
"parameters": [
|
||||
@@ -1430,6 +1460,37 @@
|
||||
"repositories"
|
||||
]
|
||||
},
|
||||
"SnapshotSummaryDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filesNew": {
|
||||
"type": "number"
|
||||
},
|
||||
"filesChanged": {
|
||||
"type": "number"
|
||||
},
|
||||
"filesUnmodified": {
|
||||
"type": "number"
|
||||
},
|
||||
"totalFiles": {
|
||||
"type": "number"
|
||||
},
|
||||
"totalBytes": {
|
||||
"type": "number"
|
||||
},
|
||||
"dataAdded": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"filesNew",
|
||||
"filesChanged",
|
||||
"filesUnmodified",
|
||||
"totalFiles",
|
||||
"totalBytes",
|
||||
"dataAdded"
|
||||
]
|
||||
},
|
||||
"SnapshotDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1444,6 +1505,9 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/components/schemas/SnapshotSummaryDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1557,12 +1621,22 @@
|
||||
"failed"
|
||||
]
|
||||
},
|
||||
"RunType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"backup",
|
||||
"restore"
|
||||
]
|
||||
},
|
||||
"RunDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"repositoryId": {
|
||||
"type": "string"
|
||||
},
|
||||
"start": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1578,14 +1652,23 @@
|
||||
"$ref": "#/components/schemas/RunStatus"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/RunType"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"repositoryId",
|
||||
"start",
|
||||
"end",
|
||||
"logFilePath",
|
||||
"status"
|
||||
"status",
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"RunHistoryResponseDto": {
|
||||
@@ -1644,6 +1727,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"RunResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"run": {
|
||||
"$ref": "#/components/schemas/RunDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"run"
|
||||
]
|
||||
},
|
||||
"TaskType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orchestration-api",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.69",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Controller, Param, Sse } from '@nestjs/common';
|
||||
import { ApiParam } from '@nestjs/swagger';
|
||||
import { Controller, Get, Param, Sse } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiParam } from '@nestjs/swagger';
|
||||
import { Observable } from 'rxjs';
|
||||
import { RunResponseDto } from '../dto/repository.dto';
|
||||
import { RunHistoryRepository } from '../repositories/runHistory.repository';
|
||||
|
||||
@Controller('/yucca/logs')
|
||||
export class RunHistoryController {
|
||||
constructor(private readonly repository: RunHistoryRepository) {}
|
||||
|
||||
@Sse('/:id')
|
||||
@Get('/:id')
|
||||
@ApiParam({ name: 'id', type: String })
|
||||
@ApiOkResponse({ type: RunResponseDto })
|
||||
async getRun(@Param('id') id: string): Promise<RunResponseDto> {
|
||||
return { run: await this.repository.get(id) };
|
||||
}
|
||||
|
||||
@Sse('/:id/stream')
|
||||
@ApiParam({ name: 'id', type: String })
|
||||
logStreamSse(@Param('id') id: string): Observable<MessageEvent> {
|
||||
return this.repository.getObservable(id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
import { BackendType, TaskStatus } from '../enum';
|
||||
import { BackendType, TaskStatus, TaskType } from '../enum';
|
||||
import type { RunType } from '../schema/tables/runHistory.table';
|
||||
|
||||
export class RepositoryDto {
|
||||
@ApiProperty({ type: String })
|
||||
@@ -120,6 +121,9 @@ export class RunDto {
|
||||
@ApiProperty({ type: String })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
repositoryId!: string;
|
||||
|
||||
@ApiProperty({ type: String })
|
||||
start!: string;
|
||||
|
||||
@@ -131,6 +135,9 @@ export class RunDto {
|
||||
|
||||
@ApiProperty({ enumName: 'RunStatus', enum: TaskStatus })
|
||||
status!: TaskStatus;
|
||||
|
||||
@ApiProperty({ enumName: 'RunType', enum: [TaskType.Backup, TaskType.Restore] })
|
||||
type!: RunType;
|
||||
}
|
||||
|
||||
export class RunHistoryResponseDto {
|
||||
@@ -138,6 +145,31 @@ export class RunHistoryResponseDto {
|
||||
runs!: RunDto[];
|
||||
}
|
||||
|
||||
export class RunResponseDto {
|
||||
@ApiProperty({ type: RunDto })
|
||||
run!: RunDto;
|
||||
}
|
||||
|
||||
export class SnapshotSummaryDto {
|
||||
@ApiProperty({ type: Number })
|
||||
filesNew!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
filesChanged!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
filesUnmodified!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
totalFiles!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
totalBytes!: number;
|
||||
|
||||
@ApiProperty({ type: Number })
|
||||
dataAdded!: number;
|
||||
}
|
||||
|
||||
export class SnapshotDto {
|
||||
@ApiProperty({ type: String })
|
||||
id!: string;
|
||||
@@ -147,6 +179,9 @@ export class SnapshotDto {
|
||||
|
||||
@ApiProperty({ type: [String] })
|
||||
paths!: string[];
|
||||
|
||||
@ApiProperty({ type: SnapshotSummaryDto, required: false })
|
||||
summary?: SnapshotSummaryDto;
|
||||
}
|
||||
|
||||
export class ListSnapshotsResponseDto {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { BackendDto } from '../dto/backend.dto';
|
||||
import { IntegrationsResponseDto } from '../dto/integrations.dto';
|
||||
import { LocalRepositoryDto } from '../dto/repository.dto';
|
||||
import { LocalRepositoryDto, RunDto } from '../dto/repository.dto';
|
||||
import { RunningTaskDto } from '../dto/runningTasks.dto';
|
||||
import { ScheduleDto } from '../dto/schedule.dto';
|
||||
import { ModuleConfigRepository } from '../repositories/moduleConfig.repository';
|
||||
@@ -56,6 +56,16 @@ export type GatewayEvent =
|
||||
type: 'TaskEnd';
|
||||
parentId: string;
|
||||
}
|
||||
| {
|
||||
type: 'RunCreate';
|
||||
run: RunDto;
|
||||
}
|
||||
| {
|
||||
type: 'RunUpdate';
|
||||
runId: string;
|
||||
repositoryId: string;
|
||||
run: Partial<RunDto>;
|
||||
}
|
||||
| {
|
||||
type: 'DeviceFlowFailure';
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { backup, forget, init, keyList, ls, restore, snapshots, stats } from '@f
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Writable } from 'node:stream';
|
||||
import { RepositorySnapshotRestoreRequestDto } from '../dto/repository.dto';
|
||||
import { createSampledLogWriter } from '../utils/restic';
|
||||
|
||||
@Injectable()
|
||||
export class ResticRepository {
|
||||
@@ -10,16 +11,14 @@ export class ResticRepository {
|
||||
}
|
||||
|
||||
async backup(repository: string, key: Uint8Array, paths: string[], logStream?: Writable, signal?: AbortSignal) {
|
||||
const write = createSampledLogWriter(logStream);
|
||||
|
||||
await backup()
|
||||
.repository(repository)
|
||||
.password(Buffer.from(key).toString('hex'))
|
||||
.addFile(...paths)
|
||||
.signal(signal)
|
||||
.on('event', (event) => {
|
||||
if (logStream?.writable) {
|
||||
logStream.write(JSON.stringify(event) + '\n');
|
||||
}
|
||||
})
|
||||
.on('event', write)
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -31,17 +30,15 @@ export class ResticRepository {
|
||||
logStream?: Writable,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const write = createSampledLogWriter(logStream);
|
||||
|
||||
let command = restore()
|
||||
.repository(repository)
|
||||
.password(Buffer.from(key).toString('hex'))
|
||||
.snapshot(snapshotId)
|
||||
.target(target ?? '/')
|
||||
.signal(signal)
|
||||
.on('event', (event) => {
|
||||
if (logStream?.writable) {
|
||||
logStream.write(JSON.stringify(event) + '\n');
|
||||
}
|
||||
});
|
||||
.on('event', write);
|
||||
|
||||
if (include) {
|
||||
command = command.include(...include);
|
||||
|
||||
@@ -8,7 +8,9 @@ import { dirname, resolve } from 'node:path';
|
||||
import { from } from 'rxjs';
|
||||
import { Tail } from 'tail';
|
||||
import { TaskStatus } from '../enum';
|
||||
import { EventsGateway } from '../events/events.gateway';
|
||||
import { DB } from '../schema';
|
||||
import { type RunType } from '../schema/tables/runHistory.table';
|
||||
import { ModuleConfigRepository } from './moduleConfig.repository';
|
||||
import { StorageRepository } from './storage.repository';
|
||||
|
||||
@@ -18,6 +20,7 @@ export class RunHistoryRepository {
|
||||
@InjectKysely('orchestrator') private db: Kysely<DB>,
|
||||
private readonly moduleConfig: ModuleConfigRepository,
|
||||
private readonly storage: StorageRepository,
|
||||
private readonly events: EventsGateway,
|
||||
) {}
|
||||
|
||||
private writeError(log: WriteStream, error: unknown) {
|
||||
@@ -32,6 +35,7 @@ export class RunHistoryRepository {
|
||||
|
||||
async createLog(
|
||||
repositoryId: string,
|
||||
type: RunType,
|
||||
fn: (log: WriteStream, logId: string) => Promise<void>,
|
||||
callback: (error?: unknown) => unknown,
|
||||
) {
|
||||
@@ -47,30 +51,41 @@ export class RunHistoryRepository {
|
||||
|
||||
const log = this.storage.createWriteStream(logFilePath);
|
||||
|
||||
await this.db
|
||||
const run = await this.db
|
||||
.insertInto('runHistory')
|
||||
.values({
|
||||
id: logId,
|
||||
repositoryId,
|
||||
type,
|
||||
|
||||
start,
|
||||
logFilePath,
|
||||
|
||||
status: TaskStatus.Incomplete,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
this.events.publish({ type: 'RunCreate', run });
|
||||
|
||||
const finalize = async (status: TaskStatus) => {
|
||||
const end = new Date().toISOString();
|
||||
await this.db.updateTable('runHistory').where('id', '=', logId).set('status', status).set('end', end).execute();
|
||||
|
||||
this.events.publish({
|
||||
type: 'RunUpdate',
|
||||
runId: logId,
|
||||
repositoryId,
|
||||
run: { status, end },
|
||||
});
|
||||
};
|
||||
|
||||
fn(log, logId)
|
||||
.then(async () => {
|
||||
callback();
|
||||
log.close();
|
||||
|
||||
await this.db
|
||||
.updateTable('runHistory')
|
||||
.where('id', '=', logId)
|
||||
.set('status', TaskStatus.Complete)
|
||||
.set('end', new Date().toISOString())
|
||||
.execute();
|
||||
await finalize(TaskStatus.Complete);
|
||||
})
|
||||
.catch(async (error) => {
|
||||
callback(error);
|
||||
@@ -78,12 +93,7 @@ export class RunHistoryRepository {
|
||||
this.writeError(log, error);
|
||||
log.close();
|
||||
|
||||
await this.db
|
||||
.updateTable('runHistory')
|
||||
.where('id', '=', logId)
|
||||
.set('status', TaskStatus.Failed)
|
||||
.set('end', new Date().toISOString())
|
||||
.execute();
|
||||
await finalize(TaskStatus.Failed);
|
||||
});
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
@@ -128,10 +138,10 @@ export class RunHistoryRepository {
|
||||
|
||||
private ephemeralLogs = new Map<string, string>();
|
||||
|
||||
createLogAsync(repositoryId: string, fn: (log: WriteStream) => Promise<void>) {
|
||||
createLogAsync(repositoryId: string, type: RunType, fn: (log: WriteStream) => Promise<void>) {
|
||||
return new Promise<void>(
|
||||
(resolve, reject) =>
|
||||
void this.createLog(repositoryId, fn, (error) => {
|
||||
void this.createLog(repositoryId, type, fn, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
@@ -164,6 +174,10 @@ export class RunHistoryRepository {
|
||||
});
|
||||
|
||||
tail.on('line', (data) => queue.push({ data } as MessageEvent));
|
||||
tail.on('error', (error) => {
|
||||
console.warn(`tail ${logFilePath} stopped:`, error);
|
||||
tail?.unwatch();
|
||||
});
|
||||
};
|
||||
|
||||
if (ephemeralPath) {
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('runHistory')
|
||||
.addColumn('type', 'text', (col) =>
|
||||
col
|
||||
.notNull()
|
||||
.defaultTo('backup')
|
||||
.check(sql`type IN ('backup', 'restore')`),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('runHistory').dropColumn('type').execute();
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { TaskStatus } from '../../enum';
|
||||
import { TaskStatus, TaskType } from '../../enum';
|
||||
|
||||
export type RunType = TaskType.Backup | TaskType.Restore;
|
||||
|
||||
export class RunHistoryTable {
|
||||
id!: string;
|
||||
@@ -9,4 +11,5 @@ export class RunHistoryTable {
|
||||
|
||||
logFilePath!: string;
|
||||
status!: TaskStatus;
|
||||
type!: RunType;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,6 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException('No events received');
|
||||
throw new InternalServerErrorException('Failed to start authentication with FUTO Backups');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export class IntegrationsService {
|
||||
repositoryId = existing.id;
|
||||
scheduleId = existing.scheduleId;
|
||||
await this.repositoryService.updateRepository(existing.id, { name: dto.name });
|
||||
await this.scheduleService.updateSchedule(scheduleId, { cron: dto.cron });
|
||||
await this.scheduleService.applyScheduleUpdate(scheduleId, { cron: dto.cron });
|
||||
} else {
|
||||
({
|
||||
repository: { id: repositoryId },
|
||||
|
||||
@@ -381,17 +381,17 @@ export class RepositoryService {
|
||||
throw new BadRequestException('Task already running!');
|
||||
}
|
||||
|
||||
const { backendId } = await this.repository.get(id);
|
||||
const { configuration } = await this.backend.getBackend(backendId);
|
||||
const backend = Backend.from(configuration, this.moduleConfig.get());
|
||||
|
||||
const { endpoint, key } = await this.getResticParameters(id);
|
||||
|
||||
const paths = await this.repositoryPath.get(id);
|
||||
if (paths.length === 0) {
|
||||
throw new BadRequestException('Missing configuration paths');
|
||||
}
|
||||
|
||||
const { endpoint, key } = await this.getResticParameters(id);
|
||||
|
||||
const { backendId } = await this.repository.get(id);
|
||||
const { configuration } = await this.backend.getBackend(backendId);
|
||||
const backend = Backend.from(configuration, this.moduleConfig.get());
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -399,6 +399,7 @@ export class RepositoryService {
|
||||
(complete, fail) =>
|
||||
void this.runHistory.createLog(
|
||||
id,
|
||||
TaskType.Backup,
|
||||
async (log, logId) => {
|
||||
resolve({
|
||||
task,
|
||||
@@ -514,9 +515,19 @@ export class RepositoryService {
|
||||
const snapshots = await this.restic.snapshots(endpoint, key);
|
||||
|
||||
return {
|
||||
snapshots: snapshots.map((snapshot) => ({
|
||||
snapshots: snapshots.map(({ summary, ...snapshot }) => ({
|
||||
...snapshot,
|
||||
time: snapshot.time.toISOString(),
|
||||
summary: summary
|
||||
? {
|
||||
filesNew: summary.files_new,
|
||||
filesChanged: summary.files_changed,
|
||||
filesUnmodified: summary.files_unmodified,
|
||||
totalFiles: summary.total_files_processed,
|
||||
totalBytes: summary.total_bytes_processed,
|
||||
dataAdded: summary.data_added,
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -534,6 +545,7 @@ export class RepositoryService {
|
||||
(complete, fail) =>
|
||||
void this.runHistory.createLog(
|
||||
id,
|
||||
TaskType.Restore,
|
||||
async (log, logId) => {
|
||||
resolve({
|
||||
task,
|
||||
|
||||
@@ -163,15 +163,19 @@ export class ScheduleService {
|
||||
};
|
||||
}
|
||||
|
||||
async updateSchedule(
|
||||
scheduleId: string,
|
||||
{ name, paused, cron, repositories }: ScheduleUpdateRequestDto,
|
||||
): Promise<ScheduleUpdateResponseDto> {
|
||||
async updateSchedule(scheduleId: string, dto: ScheduleUpdateRequestDto): Promise<ScheduleUpdateResponseDto> {
|
||||
const integration = await this.integrationImmich.get();
|
||||
if (integration?.scheduleId === scheduleId) {
|
||||
if (integration?.scheduleId === scheduleId && !(typeof dto.paused === 'boolean' && Object.keys(dto).length === 1)) {
|
||||
throw new BadRequestException('Schedule managed by Immich integration');
|
||||
}
|
||||
|
||||
return this.applyScheduleUpdate(scheduleId, dto);
|
||||
}
|
||||
|
||||
async applyScheduleUpdate(
|
||||
scheduleId: string,
|
||||
{ name, paused, cron, repositories }: ScheduleUpdateRequestDto,
|
||||
): Promise<ScheduleUpdateResponseDto> {
|
||||
const linked = new Set(await this.schedule.getRepositoryIds(scheduleId));
|
||||
|
||||
const set: Updateable<ScheduleTable> = {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Writable } from 'node:stream';
|
||||
|
||||
type ResticStatusEvent = { message_type: string; percent_done?: number };
|
||||
|
||||
export const createSampledLogWriter = (logStream: Writable | undefined) => {
|
||||
let lastAt = 0;
|
||||
let lastPercent = -1;
|
||||
|
||||
return (event: ResticStatusEvent) => {
|
||||
if (!logStream?.writable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.message_type === 'status') {
|
||||
const percent = event.percent_done ?? 0;
|
||||
if (percent < 1 && Date.now() - lastAt < 1000 && Math.abs(percent - lastPercent) < 0.01) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAt = Date.now();
|
||||
lastPercent = percent;
|
||||
}
|
||||
|
||||
logStream.write(JSON.stringify(event) + '\n');
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orchestration-ui",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.69",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 5174",
|
||||
@@ -72,7 +72,7 @@
|
||||
"@oazapfts/runtime": "^1.1.0",
|
||||
"@tanstack/svelte-query": "^6.1.3",
|
||||
"cron-validate": "^1.5.3",
|
||||
"d3": "^7.9.0",
|
||||
"cronstrue": "^3.14.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"luxon": "^3.7.2",
|
||||
"socket.io-client": "^4.8.3",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
BackendDto,
|
||||
BackendType,
|
||||
RepositoryBackendDto,
|
||||
} from "$lib/fetch-client";
|
||||
import { getBackendActions } from "$lib/services/backend.service";
|
||||
import { Badge, Icon, Text } from "@immich/ui";
|
||||
import { mdiCloudOutline, mdiHarddisk, mdiShieldCheckOutline } from "@mdi/js";
|
||||
import StackListItem from "../ui/StackListItem.svelte";
|
||||
|
||||
type Props = {
|
||||
backend: BackendDto;
|
||||
repositoryBackend?: RepositoryBackendDto & { primary?: boolean };
|
||||
};
|
||||
|
||||
const { backend, repositoryBackend }: Props = $props();
|
||||
|
||||
const BackendIcons: Record<BackendType, string> = {
|
||||
yucca: mdiShieldCheckOutline,
|
||||
local: mdiHarddisk,
|
||||
s3: mdiCloudOutline,
|
||||
};
|
||||
|
||||
const BackendNames: Record<BackendType, string> = {
|
||||
yucca: "FUTO Backups",
|
||||
local: "Local Storage",
|
||||
s3: "S3 Server",
|
||||
};
|
||||
|
||||
const { LoginAgain } = $derived(getBackendActions(backend));
|
||||
</script>
|
||||
|
||||
<StackListItem actions={[LoginAgain]}>
|
||||
{#snippet icon()}
|
||||
<Icon icon={BackendIcons[backend.type]} />
|
||||
{/snippet}
|
||||
|
||||
<Text>{BackendNames[backend.type]}</Text>
|
||||
|
||||
{#if repositoryBackend?.primary}
|
||||
<Text size="small">(primary)</Text>
|
||||
{/if}
|
||||
|
||||
{#snippet trailing()}
|
||||
{#if repositoryBackend}
|
||||
<Badge
|
||||
color={backend.isOnline && repositoryBackend.online
|
||||
? "success"
|
||||
: "danger"}
|
||||
size="small"
|
||||
>{backend.isOnline
|
||||
? repositoryBackend.online
|
||||
? "Active"
|
||||
: "Missing on service"
|
||||
: "Offline"}</Badge
|
||||
>
|
||||
{:else}
|
||||
<Badge color={backend.isOnline ? "success" : "danger"} size="small"
|
||||
>{backend.isOnline ? "Online" : "Offline"}</Badge
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</StackListItem>
|
||||
+44
-69
@@ -1,98 +1,73 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardFooter,
|
||||
Heading,
|
||||
HStack,
|
||||
LoadingSpinner,
|
||||
modalManager,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import { getReadableErrorMessage } from "$lib/utils/handle-error";
|
||||
import type { LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import { options } from "$lib/options";
|
||||
import {
|
||||
handleSetupLocalStorage,
|
||||
handleYuccaLogin,
|
||||
useBackendEventHandler,
|
||||
useBackends,
|
||||
} from "$lib/services/backend.service";
|
||||
import { Button, HStack } from "@immich/ui";
|
||||
import StackList from "../ui/StackList.svelte";
|
||||
import OnEvents from "../util/OnEvents.svelte";
|
||||
import CreateLocalBackend from "./CreateLocalBackend.svelte";
|
||||
import OAuthDeviceFlow from "./OAuthDeviceFlow.svelte";
|
||||
import Suspense from "../util/Suspense.svelte";
|
||||
import BackendItem from "./BackendItem.svelte";
|
||||
|
||||
type Props = {
|
||||
repository?: LocalRepositoryDto;
|
||||
};
|
||||
|
||||
const { repository }: Props = $props();
|
||||
|
||||
const { advanced } = options;
|
||||
const query = useBackends();
|
||||
const { onBackendCreate } = useBackendEventHandler();
|
||||
|
||||
const yuccaBackend = $derived(
|
||||
query.data?.find((backend) => backend.type === "yucca"),
|
||||
const repositoryBackends = $derived(
|
||||
repository?.backends
|
||||
? [
|
||||
{
|
||||
...repository.backends.primary,
|
||||
primary: true,
|
||||
},
|
||||
...repository.backends.secondary,
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
async function getStarted() {
|
||||
modalManager.show(OAuthDeviceFlow, await handleYuccaLogin());
|
||||
}
|
||||
</script>
|
||||
|
||||
<OnEvents {onBackendCreate} />
|
||||
|
||||
{#if query.isLoading}
|
||||
<LoadingSpinner />
|
||||
{:else if query.isError}
|
||||
<Alert color="danger">{getReadableErrorMessage(query.error)}</Alert>
|
||||
{:else if query.isSuccess}
|
||||
{#if yuccaBackend}
|
||||
<Card color={yuccaBackend.isOnline ? "success" : "danger"}>
|
||||
<CardBody class="flex flex-col gap-2">
|
||||
<Heading>FUTO Backups</Heading>
|
||||
</CardBody>
|
||||
{#if yuccaBackend.isOnline}
|
||||
<CardFooter class="flex gap-2">
|
||||
<Button size="small" color="secondary">Manage my account (🚧)</Button>
|
||||
<Button size="small" color="secondary">Billing (🚧)</Button>
|
||||
</CardFooter>
|
||||
<Suspense {query}>
|
||||
<StackList>
|
||||
{#snippet title()}
|
||||
{#if repository}
|
||||
Where your backup is stored
|
||||
{:else}
|
||||
<CardFooter>
|
||||
<Button onclick={getStarted} size="small">Login again</Button>
|
||||
</CardFooter>
|
||||
Configured backends
|
||||
{/if}
|
||||
</Card>
|
||||
{:else}
|
||||
<Card color={"info"}>
|
||||
<CardBody class="flex flex-col gap-2">
|
||||
<Heading>FUTO Backups</Heading>
|
||||
<Text>Upsell text here</Text>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Button onclick={getStarted} size="small">Get started</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#each query.data as backend (backend.id)}
|
||||
{#if backend.type !== "yucca"}
|
||||
<Card color={backend.isOnline ? "success" : "danger"}>
|
||||
<CardBody class="flex flex-col gap-2">
|
||||
<Heading
|
||||
>{backend.type === "local" ? "Local Storage" : "S3 Server"}</Heading
|
||||
>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Button size="small" color="secondary">Configure (🚧)</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each query.data as backend (backend.id)}
|
||||
<BackendItem
|
||||
{backend}
|
||||
repositoryBackend={repositoryBackends.find(
|
||||
({ id }) => backend.id === id,
|
||||
)}
|
||||
/>
|
||||
{/each}
|
||||
</StackList>
|
||||
|
||||
{#if $advanced}
|
||||
<HStack>
|
||||
<Button size="small" variant="outline" onclick={() => handleYuccaLogin()}
|
||||
>Login with FUTO Backups</Button
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
onclick={() => modalManager.open(CreateLocalBackend, {})}
|
||||
>Setup new local storage</Button
|
||||
variant="outline"
|
||||
onclick={() => handleSetupLocalStorage()}>New local storage</Button
|
||||
>
|
||||
<Button size="small">Setup new S3 storage (🚧)</Button>
|
||||
</HStack>
|
||||
{/if}
|
||||
{/if}
|
||||
</Suspense>
|
||||
|
||||
+16
-24
@@ -1,30 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Button, Field, FormModal, HStack, Input, Stack } from "@immich/ui";
|
||||
import { modalManager } from "@immich/ui";
|
||||
import FileBrowserModal from "$lib/components/backups/dialogs/FileBrowserModal.svelte";
|
||||
import PathPickerField from "$lib/components/ui/PathPickerField.svelte";
|
||||
import { handleCreateLocalBackend } from "$lib/services/backend.service";
|
||||
import { FormModal, Stack } from "@immich/ui";
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
onCreated?: () => void;
|
||||
}
|
||||
onCreate?: (backendId: string) => void;
|
||||
};
|
||||
|
||||
let { onClose, onCreated }: Props = $props();
|
||||
let { onClose, onCreate }: Props = $props();
|
||||
|
||||
let path = $state("");
|
||||
|
||||
const onSubmit = async () => {
|
||||
await handleCreateLocalBackend({ path });
|
||||
onCreated?.();
|
||||
const { backend } = await handleCreateLocalBackend({ path });
|
||||
onCreate?.(backend.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const browse = () => {
|
||||
modalManager.show(FileBrowserModal, {
|
||||
folders: true,
|
||||
onSelect: (selected: string) => (path = selected),
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
@@ -35,13 +27,13 @@
|
||||
{onClose}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Field label="Path" description="Local directory to store backups">
|
||||
<HStack gap={2}>
|
||||
<div class="grow">
|
||||
<Input bind:value={path} />
|
||||
</div>
|
||||
<Button size="small" variant="outline" onclick={browse}>Browse</Button>
|
||||
</HStack>
|
||||
</Field>
|
||||
<PathPickerField
|
||||
bind:value={path}
|
||||
pickerTitle="Choose backend folder"
|
||||
pickerDescription="Pick the local directory where backups will be stored."
|
||||
>
|
||||
{#snippet title()}Path{/snippet}
|
||||
{#snippet description()}Local directory to store backups.{/snippet}
|
||||
</PathPickerField>
|
||||
</Stack>
|
||||
</FormModal>
|
||||
|
||||
+43
-9
@@ -1,14 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Button, Modal, ModalBody, ModalFooter } from "@immich/ui";
|
||||
import { useBackendEventHandler } from "$lib/services/backend.service";
|
||||
import OnEvents from "../util/OnEvents.svelte";
|
||||
import type { SocketEvent } from "$lib/events";
|
||||
import type { BackendDto } from "$lib/fetch-client";
|
||||
import { useBackendEventHandler } from "$lib/services/backend.service";
|
||||
import {
|
||||
Button,
|
||||
Code,
|
||||
HStack,
|
||||
IconButton,
|
||||
LoadingSpinner,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Stack,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@immich/ui";
|
||||
import { mdiContentCopy } from "@mdi/js";
|
||||
import OnEvents from "../util/OnEvents.svelte";
|
||||
|
||||
interface Props {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
onCreate?: () => void;
|
||||
onCreate?: (backendId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -18,7 +31,7 @@
|
||||
|
||||
function onBackendCreate(event: SocketEvent<{ backend: BackendDto }>) {
|
||||
onBackendCreateHandler(event);
|
||||
onCreate?.();
|
||||
onCreate?.(event.data.backend.id);
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -33,12 +46,33 @@
|
||||
|
||||
<OnEvents {onBackendCreate} />
|
||||
|
||||
<Modal title="Login with FUTO Backups" {onClose}>
|
||||
<Modal title="Logging into FUTO Backups" icon={false} {onClose}>
|
||||
<ModalBody>
|
||||
If asked, enter the code <code>{userCode}</code>
|
||||
<VStack>
|
||||
<Text>You may be asked or shown the following code:</Text>
|
||||
<Stack direction="row" align="center">
|
||||
<Code class="text-3xl select-all">{userCode}</Code>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
icon={mdiContentCopy}
|
||||
onclick={onCopy}
|
||||
aria-label="Copy code"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<HStack class="mt-4">
|
||||
<LoadingSpinner />
|
||||
<Text>Waiting for you to confirm login...</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onclick={onOpen}>Open link again</Button>
|
||||
<Button onclick={onCopy}>Copy code</Button>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button shape="round" fullWidth onclick={onOpen}>Open login again</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import { handleCreateBackup } from "$lib/services/repository.service";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
@@ -11,11 +12,6 @@
|
||||
modalManager,
|
||||
Stack,
|
||||
} from "@immich/ui";
|
||||
import { handleCreateBackup } from "$lib/services/repository.service";
|
||||
import RunHistoryModal from "./dialogs/RunHistoryModal.svelte";
|
||||
import ConfigureRepositoryModal from "./dialogs/ConfigureRepositoryModal.svelte";
|
||||
import SnapshotsListModal from "./dialogs/SnapshotsListModal.svelte";
|
||||
import ViewLogModal from "./dialogs/ViewLogModal.svelte";
|
||||
import {
|
||||
mdiCog,
|
||||
mdiFormatListBulletedType,
|
||||
@@ -24,7 +20,10 @@
|
||||
mdiPlay,
|
||||
} from "@mdi/js";
|
||||
import RelativeTime from "../util/RelativeTime.svelte";
|
||||
import ConfigureRepositoryModal from "./dialogs/ConfigureRepositoryModal.svelte";
|
||||
import ImportRepositoryModal from "./dialogs/ImportRepositoryModal.svelte";
|
||||
import SnapshotsListModal from "./snapshots-list/SnapshotsListModal.svelte";
|
||||
import RunHistoryModal from "./run-history/RunHistoryModal.svelte";
|
||||
|
||||
type Props = {
|
||||
repository: LocalRepositoryDto;
|
||||
@@ -32,10 +31,7 @@
|
||||
|
||||
const { repository }: Props = $props();
|
||||
|
||||
const onBackupNow = async () => {
|
||||
const { logId } = await handleCreateBackup(repository.id);
|
||||
modalManager.open(ViewLogModal, { logId });
|
||||
};
|
||||
const onBackupNow = () => void handleCreateBackup(repository.id);
|
||||
|
||||
const onViewHistory = () =>
|
||||
modalManager.open(RunHistoryModal, {
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { RepositoryListResponseDto } from "$lib/fetch-client";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Heading,
|
||||
LoadingSpinner,
|
||||
modalManager,
|
||||
} from "@immich/ui";
|
||||
import { getReadableErrorMessage } from "$lib/utils/handle-error";
|
||||
import BackupItem from "./BackupItem.svelte";
|
||||
import CreateRepositoryModal from "./dialogs/CreateRepositoryModal.svelte";
|
||||
import OnEvents from "../util/OnEvents.svelte";
|
||||
import {
|
||||
useRepositories,
|
||||
useRepositoryEventHandler,
|
||||
} from "$lib/services/repository.service";
|
||||
import { Button, Heading, modalManager } from "@immich/ui";
|
||||
import OnEvents from "../util/OnEvents.svelte";
|
||||
import Suspense from "../util/Suspense.svelte";
|
||||
import BackupItem from "./BackupItem.svelte";
|
||||
import CreateRepositoryModal from "./dialogs/CreateRepositoryModal.svelte";
|
||||
|
||||
interface Props {
|
||||
local?: boolean;
|
||||
@@ -42,11 +36,7 @@
|
||||
|
||||
<OnEvents {onRepositoryCreate} {onRepositoryUpdate} {onRepositoryDelete} />
|
||||
|
||||
{#if query.isLoading}
|
||||
<LoadingSpinner />
|
||||
{:else if query.isError}
|
||||
<Alert color="danger">{getReadableErrorMessage(query.error)}</Alert>
|
||||
{:else if query.isSuccess}
|
||||
<Suspense {query}>
|
||||
<div class="flex flex-col gap-4">
|
||||
{#if local}
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -78,4 +68,4 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Suspense>
|
||||
|
||||
-208
@@ -1,208 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Checkbox,
|
||||
Field,
|
||||
FormModal,
|
||||
Input,
|
||||
LoadingSpinner,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import validate from "cron-validate";
|
||||
import { sdk } from "$lib";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { useIntegrations } from "$lib/services/integrations.service";
|
||||
import { useSchedules } from "$lib/services/schedule.service";
|
||||
import { getReadableErrorMessage } from "$lib/utils/handle-error";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
initialConfiguration?: boolean;
|
||||
}
|
||||
|
||||
let { initialConfiguration = false, onClose }: Props = $props();
|
||||
|
||||
let name = $state("Immich");
|
||||
let cron = $state("0 3 * * *");
|
||||
let worm = $state(false);
|
||||
|
||||
let includeYuccaConfig = $state(true);
|
||||
let allLibraries = $state(true);
|
||||
let selectedFolders = new SvelteSet<string>();
|
||||
let selectedLibraries = new SvelteSet<string>();
|
||||
|
||||
const folderLabels: Record<string, string> = {
|
||||
"encoded-video": "Encoded Video",
|
||||
library: "Library",
|
||||
upload: "Uploads",
|
||||
profile: "Profile Pictures",
|
||||
thumbs: "Thumbnails",
|
||||
backups: "Database Backups",
|
||||
};
|
||||
|
||||
const query = useIntegrations();
|
||||
const schedulesQuery = useSchedules();
|
||||
|
||||
let populated = false;
|
||||
|
||||
$effect(() => {
|
||||
if (populated || !query.data?.immichState) return;
|
||||
populated = true;
|
||||
|
||||
const integration = query.data.immichIntegration;
|
||||
if (integration) {
|
||||
const config = integration.configuration;
|
||||
includeYuccaConfig = config.backupConfiguration;
|
||||
allLibraries = config.libraries === "all";
|
||||
|
||||
for (const folder of config.dataFolders) {
|
||||
selectedFolders.add(folder);
|
||||
}
|
||||
|
||||
if (Array.isArray(config.libraries)) {
|
||||
for (const id of config.libraries) {
|
||||
selectedLibraries.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = schedulesQuery.data?.find(
|
||||
(schedule) => schedule.id === integration.scheduleId,
|
||||
);
|
||||
|
||||
if (schedule) {
|
||||
cron = schedule.cron;
|
||||
}
|
||||
} else {
|
||||
for (const folder of query.data.immichState.dataFolders) {
|
||||
selectedFolders.add(folder);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function toggleFolder(folder: string) {
|
||||
if (selectedFolders.has(folder)) {
|
||||
selectedFolders.delete(folder);
|
||||
} else {
|
||||
selectedFolders.add(folder);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLibrary(id: string) {
|
||||
if (selectedLibraries.has(id)) {
|
||||
selectedLibraries.delete(id);
|
||||
} else {
|
||||
selectedLibraries.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
await sdk.configureImmichIntegration({
|
||||
name,
|
||||
cron,
|
||||
worm,
|
||||
dataFolders: [...selectedFolders],
|
||||
backupConfiguration: includeYuccaConfig,
|
||||
libraries: allLibraries ? "all" : [...selectedLibraries],
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
title="Configure Your Immich Backup"
|
||||
disabled={name.length === 0 || validate(cron).isError()}
|
||||
{onClose}
|
||||
{onSubmit}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Field label="Name" description="A memorable name for this backup">
|
||||
<Input bind:value={name} />
|
||||
</Field>
|
||||
<Field label="Schedule" description="Uses cron syntax">
|
||||
<Input bind:value={cron} />
|
||||
</Field>
|
||||
|
||||
{#if initialConfiguration}
|
||||
<Field
|
||||
label="Write once (WORM)"
|
||||
description="Prevent anything being deleted"
|
||||
>
|
||||
<Checkbox bind:checked={worm} />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
{#if query.isLoading}
|
||||
<LoadingSpinner />
|
||||
{:else if query.isError}
|
||||
<Alert color="danger">{getReadableErrorMessage(query.error)}</Alert>
|
||||
{:else if query.isSuccess && query.data.immichState}
|
||||
{@const immich = query.data.immichState}
|
||||
|
||||
<Stack gap={2}>
|
||||
<div>
|
||||
<Text fontWeight="bold">Data Folders</Text>
|
||||
<Text size="small" color="secondary"
|
||||
>Select which Immich data to include in backups</Text
|
||||
>
|
||||
</div>
|
||||
{#each immich.dataFolders as folder}
|
||||
<label class="select-none flex gap-2 items-center">
|
||||
<Checkbox
|
||||
checked={selectedFolders.has(folder)}
|
||||
onCheckedChange={() => toggleFolder(folder)}
|
||||
/>
|
||||
{folderLabels[folder] ?? folder}
|
||||
</label>
|
||||
{/each}
|
||||
</Stack>
|
||||
|
||||
<Stack gap={2}>
|
||||
<div>
|
||||
<Text fontWeight="bold">Backup Configuration</Text>
|
||||
<Text size="small" color="secondary"
|
||||
>Include your backup settings & schedules</Text
|
||||
>
|
||||
</div>
|
||||
<label class="select-none flex gap-2 items-center">
|
||||
<Checkbox bind:checked={includeYuccaConfig} />
|
||||
Include yucca configuration
|
||||
</label>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={2}>
|
||||
<div>
|
||||
<Text fontWeight="bold">External Libraries</Text>
|
||||
<Text size="small" color="secondary"
|
||||
>Choose which external libraries to back up</Text
|
||||
>
|
||||
</div>
|
||||
<label class="select-none flex gap-2 items-center">
|
||||
<Checkbox
|
||||
bind:checked={allLibraries}
|
||||
onCheckedChange={() => selectedLibraries.clear()}
|
||||
/>
|
||||
All libraries (existing and future)
|
||||
</label>
|
||||
|
||||
{#if !allLibraries}
|
||||
{#each immich.libraries as library}
|
||||
<label class="select-none flex gap-2 items-center pl-4">
|
||||
<Checkbox
|
||||
checked={selectedLibraries.has(library.id)}
|
||||
onCheckedChange={() => toggleLibrary(library.id)}
|
||||
/>
|
||||
{library.name}
|
||||
</label>
|
||||
{/each}
|
||||
{#if immich.libraries.length === 0}
|
||||
<Text size="small" color="secondary"
|
||||
>No external libraries found.</Text
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</Stack>
|
||||
{/if}
|
||||
</Stack>
|
||||
</FormModal>
|
||||
+27
-46
@@ -1,22 +1,20 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Button,
|
||||
FormModal,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
modalManager,
|
||||
Stack,
|
||||
Text,
|
||||
Field,
|
||||
} from "@immich/ui";
|
||||
import BackendsList from "$lib/components/backends/BackendsList.svelte";
|
||||
import { type LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import FileBrowserModal from "./FileBrowserModal.svelte";
|
||||
import { options } from "$lib/options";
|
||||
import {
|
||||
handleRemoveRepository,
|
||||
handleUpdateRepository,
|
||||
} from "$lib/services/repository.service";
|
||||
import PathListField from "$lib/components/ui/PathListField.svelte";
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
FormModal,
|
||||
Input,
|
||||
modalManager,
|
||||
Stack,
|
||||
} from "@immich/ui";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
interface Props {
|
||||
@@ -57,6 +55,8 @@
|
||||
|
||||
await handleRemoveRepository(repository.id, local);
|
||||
};
|
||||
|
||||
const { advanced } = options;
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
@@ -74,41 +74,22 @@
|
||||
</Stack>
|
||||
|
||||
{#if repository.configuration}
|
||||
<Stack gap={1}>
|
||||
<Text size="small">Backup Paths</Text>
|
||||
{#each paths as path (path)}
|
||||
<HStack
|
||||
gap={2}
|
||||
class="items-center py-2 px-4 bg-gray-100 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<Text class="grow" size="small">{path}</Text>
|
||||
<IconButton
|
||||
icon={mdiClose}
|
||||
size="tiny"
|
||||
color="danger"
|
||||
aria-label="Remove"
|
||||
onclick={() => paths.delete(path)}
|
||||
/>
|
||||
</HStack>
|
||||
{/each}
|
||||
|
||||
{#if paths.size === 0}
|
||||
<Text color="secondary" size="small">No paths configured yet.</Text>
|
||||
{/if}
|
||||
|
||||
<div class="w-fit">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onclick={() =>
|
||||
modalManager.show(FileBrowserModal, {
|
||||
onSelect: (path) => paths.add(path),
|
||||
})}>Add path</Button
|
||||
>
|
||||
</div>
|
||||
</Stack>
|
||||
<PathListField
|
||||
{paths}
|
||||
addLabel="Add path"
|
||||
manageLabel="Add first path"
|
||||
pickerTitle="Backup Paths"
|
||||
pickerDescription="Select files and folders to include in this backup."
|
||||
>
|
||||
{#snippet label()}Backup Paths{/snippet}
|
||||
{#snippet empty()}No paths configured yet.{/snippet}
|
||||
</PathListField>
|
||||
{/if}
|
||||
|
||||
<Button color="danger" onclick={onRemove}>Remove Repository</Button>
|
||||
|
||||
{#if advanced}
|
||||
<BackendsList {repository} />
|
||||
{/if}
|
||||
</Stack>
|
||||
</FormModal>
|
||||
|
||||
+7
-2
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { handleCreateRepository } from "$lib/services/repository.service";
|
||||
import {
|
||||
Checkbox,
|
||||
Field,
|
||||
@@ -7,7 +8,6 @@
|
||||
modalManager,
|
||||
Stack,
|
||||
} from "@immich/ui";
|
||||
import { handleCreateRepository } from "$lib/services/repository.service";
|
||||
import ConfigureRepositoryModal from "./ConfigureRepositoryModal.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -33,7 +33,12 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal title="Create A New Backup" disabled={name.length === 0} {onSubmit} {onClose}>
|
||||
<FormModal
|
||||
title="Create A New Backup"
|
||||
disabled={name.length === 0}
|
||||
{onSubmit}
|
||||
{onClose}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Field label="Name" description="A memorable name for this backup">
|
||||
<Input bind:value={name} />
|
||||
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
HStack,
|
||||
IconButton,
|
||||
LoadingSpinner,
|
||||
Modal,
|
||||
ModalBody,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import { mdiFolderOpen, mdiFolderPlus, mdiPlus } from "@mdi/js";
|
||||
import type { FilesystemListingResponseDto } from "$lib/fetch-client";
|
||||
import { handleGetFileListing } from "$lib/services/filesystem.service";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
folders?: boolean;
|
||||
handleGetListing?: (path?: string) => Promise<FilesystemListingResponseDto>;
|
||||
}
|
||||
|
||||
const {
|
||||
onSelect,
|
||||
onClose,
|
||||
folders = false,
|
||||
handleGetListing,
|
||||
}: Props = $props();
|
||||
|
||||
let listing: FilesystemListingResponseDto | undefined = $state();
|
||||
|
||||
async function open(path?: string) {
|
||||
listing = undefined;
|
||||
listing = await (handleGetListing ?? handleGetFileListing)(path);
|
||||
}
|
||||
|
||||
onMount(() => open());
|
||||
</script>
|
||||
|
||||
{#if listing}
|
||||
<Modal title={listing.path} {onClose}>
|
||||
<ModalBody>
|
||||
<Stack gap={1}>
|
||||
<HStack gap={2} class="items-center py-2 px-4">
|
||||
<IconButton
|
||||
icon={mdiFolderOpen}
|
||||
aria-label="Go up directory"
|
||||
size="tiny"
|
||||
onclick={() => open(listing!.parent)}
|
||||
/>
|
||||
<Text size="small" class="grow">..</Text>
|
||||
</HStack>
|
||||
|
||||
{#each listing.items as item (item.path)}
|
||||
{#if item.isDirectory || !folders}
|
||||
<HStack gap={2} class="items-center py-2 px-4">
|
||||
{#if item.isDirectory}
|
||||
<IconButton
|
||||
icon={mdiFolderOpen}
|
||||
aria-label="Open folder"
|
||||
size="tiny"
|
||||
onclick={() => open(item.path)}
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-7"></div>
|
||||
{/if}
|
||||
<Text size="small" class="grow"
|
||||
>{item.path.split(/\\|\//).pop()}</Text
|
||||
>
|
||||
<IconButton
|
||||
icon={folders ? mdiFolderPlus : mdiPlus}
|
||||
aria-label={folders ? "Select folder" : "Add"}
|
||||
size="tiny"
|
||||
onclick={() => {
|
||||
onSelect(item.path);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
{/if}
|
||||
{/each}
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
{:else}
|
||||
<Modal title="Loading..." {onClose}>
|
||||
<ModalBody>
|
||||
<LoadingSpinner />
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
{/if}
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { type LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import { FormModal, LoadingSpinner, modalManager, Text } from "@immich/ui";
|
||||
import { onMount } from "svelte";
|
||||
import ConfigureRepositoryModal from "./ConfigureRepositoryModal.svelte";
|
||||
import {
|
||||
handleCheckImportRepository,
|
||||
handleImportRepository,
|
||||
} from "$lib/services/repository.service";
|
||||
import { FormModal, LoadingSpinner, modalManager, Text } from "@immich/ui";
|
||||
import { onMount } from "svelte";
|
||||
import ConfigureRepositoryModal from "./ConfigureRepositoryModal.svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
|
||||
+56
-82
@@ -1,31 +1,28 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Field,
|
||||
FormModal,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
modalManager,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import PathListField from "$lib/components/ui/PathListField.svelte";
|
||||
import PathPickerField from "$lib/components/ui/PathPickerField.svelte";
|
||||
import type { RepositorySnapshotRestoreRequestDto } from "$lib/fetch-client";
|
||||
import {
|
||||
handleGetSnapshotListing,
|
||||
handleRestoreSnapshot,
|
||||
} from "$lib/services/snapshot.service";
|
||||
import ViewLogModal from "./ViewLogModal.svelte";
|
||||
import { mdiClose, mdiFolder } from "@mdi/js";
|
||||
import FileBrowserModal from "./FileBrowserModal.svelte";
|
||||
import {
|
||||
FormModal,
|
||||
Heading,
|
||||
HStack,
|
||||
modalManager,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import type { RepositorySnapshotRestoreRequestDto } from "$lib/fetch-client";
|
||||
import ViewLogModal from "./ViewLogModal.svelte";
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
repository: string;
|
||||
snapshot: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { repository, snapshot, onClose }: Props = $props();
|
||||
|
||||
@@ -50,73 +47,50 @@
|
||||
logId,
|
||||
});
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<FormModal title="Restore Backup" {onSubmit} {onClose}>
|
||||
<Stack gap={4}>
|
||||
<Stack>
|
||||
<Text size="small">Selected Files</Text>
|
||||
{#each include as path (path)}
|
||||
<HStack
|
||||
gap={2}
|
||||
class="items-center py-2 px-4 bg-gray-100 rounded-md border border-gray-200"
|
||||
>
|
||||
<Text class="grow" size="small">{path}</Text>
|
||||
<IconButton
|
||||
icon={mdiClose}
|
||||
size="tiny"
|
||||
color="danger"
|
||||
aria-label="Remove"
|
||||
onclick={() => include.delete(path)}
|
||||
/>
|
||||
</HStack>
|
||||
{/each}
|
||||
|
||||
{#if include.size === 0}
|
||||
<Text color="secondary" size="small"
|
||||
>Restoring all files and folders.</Text
|
||||
>
|
||||
{/if}
|
||||
|
||||
<div class="w-fit">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
onclick={() =>
|
||||
modalManager.show(FileBrowserModal, {
|
||||
onSelect: (path) => include.add(path),
|
||||
handleGetListing: (path) =>
|
||||
handleGetSnapshotListing(repository, snapshot, path),
|
||||
})}>Add file or folder</Button
|
||||
>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Field
|
||||
label="In-place restore"
|
||||
description="Restore files to where they were originally"
|
||||
<FormModal title="Restore Backup" submitText="Restore" {onSubmit} {onClose}>
|
||||
<Stack gap={5}>
|
||||
<PathListField
|
||||
paths={include}
|
||||
addLabel="Add more files"
|
||||
manageLabel="Select files instead"
|
||||
pickerTitle="Files to restore"
|
||||
pickerDescription="Pick the files and folders to restore. Leave empty to restore everything."
|
||||
handleGetListing={(path) =>
|
||||
handleGetSnapshotListing(repository, snapshot, path)}
|
||||
>
|
||||
<Checkbox bind:checked={inPlace} />
|
||||
</Field>
|
||||
{#snippet label()}Files to restore{/snippet}
|
||||
{#snippet empty()}Restoring all files and folders.{/snippet}
|
||||
</PathListField>
|
||||
|
||||
{#if !inPlace}
|
||||
<Field
|
||||
label="Target"
|
||||
description="Where do you want this backup restored to?"
|
||||
>
|
||||
<HStack class="items-end">
|
||||
<Input bind:value={target} />
|
||||
<IconButton
|
||||
icon={mdiFolder}
|
||||
aria-label="Select folder"
|
||||
onclick={() =>
|
||||
modalManager.open(FileBrowserModal, {
|
||||
folders: true,
|
||||
onSelect: (value) => (target = value),
|
||||
})}
|
||||
/>
|
||||
</HStack>
|
||||
</Field>
|
||||
{/if}
|
||||
<Stack gap={4}>
|
||||
<Heading class="px-1" size="tiny">Options</Heading>
|
||||
|
||||
<HStack gap={4}>
|
||||
<Stack gap={0}>
|
||||
<Text>In-place restore</Text>
|
||||
<Text color="secondary" size="small">
|
||||
Restore files to where they were originally.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Switch bind:checked={inPlace} />
|
||||
</HStack>
|
||||
|
||||
{#if !inPlace}
|
||||
<PathPickerField
|
||||
bind:value={target}
|
||||
placeholder="/path/to/restore/into"
|
||||
pickerTitle="Choose target folder"
|
||||
pickerDescription="Pick the folder to restore files into."
|
||||
>
|
||||
{#snippet title()}Target{/snippet}
|
||||
{#snippet description()}
|
||||
Where do you want this backup restored to?
|
||||
{/snippet}
|
||||
</PathPickerField>
|
||||
{/if}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</FormModal>
|
||||
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
LoadingSpinner,
|
||||
Modal,
|
||||
ModalBody,
|
||||
modalManager,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeading,
|
||||
TableRow,
|
||||
} from "@immich/ui";
|
||||
import { getReadableErrorMessage } from "$lib/utils/handle-error";
|
||||
import type { LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import ViewLogModal from "./ViewLogModal.svelte";
|
||||
import { useRunHistory } from "$lib/services/runHistory.service";
|
||||
|
||||
interface Props {
|
||||
repository: LocalRepositoryDto;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { repository, onClose }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const query = useRunHistory(repository.id);
|
||||
</script>
|
||||
|
||||
<Modal title={`Run History for ${repository.name}`} size="giant" {onClose}>
|
||||
<ModalBody>
|
||||
{#if query.isLoading}
|
||||
<LoadingSpinner />
|
||||
{:else if query.isError}
|
||||
<Alert color="danger">{getReadableErrorMessage(query.error)}</Alert>
|
||||
{:else if query.isSuccess}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeading>Start</TableHeading>
|
||||
<TableHeading>End</TableHeading>
|
||||
<TableHeading>Status</TableHeading>
|
||||
<TableHeading></TableHeading>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{#each query.data as run (run.id)}
|
||||
<TableRow>
|
||||
<TableCell>{run.start}</TableCell>
|
||||
<TableCell>{run.end}</TableCell>
|
||||
<TableCell>{run.status}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="tiny"
|
||||
onclick={() =>
|
||||
modalManager.show(ViewLogModal, { logId: run.id })}
|
||||
>View Log</Button
|
||||
>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
IconButton,
|
||||
LoadingSpinner,
|
||||
Modal,
|
||||
ModalBody,
|
||||
modalManager,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeading,
|
||||
TableRow,
|
||||
} from "@immich/ui";
|
||||
import { getReadableErrorMessage } from "$lib/utils/handle-error";
|
||||
import { type LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
handleForgetSnapshot,
|
||||
useRemoveSnapshot,
|
||||
useSnapshots,
|
||||
} from "$lib/services/snapshot.service";
|
||||
import RestoreSnapshotModal from "./RestoreSnapshotModal.svelte";
|
||||
import { mdiDeleteOutline, mdiRestore } from "@mdi/js";
|
||||
|
||||
interface Props {
|
||||
repository: LocalRepositoryDto;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { repository, onClose }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const query = useSnapshots(repository.id);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const removeSnapshot = useRemoveSnapshot(repository.id);
|
||||
let deleting = $state(false);
|
||||
|
||||
const restoreSnapshot = (id: string) => {
|
||||
onClose();
|
||||
|
||||
modalManager.open(RestoreSnapshotModal, {
|
||||
repository: repository.id,
|
||||
snapshot: id,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteSnapshot = async (id: string) => {
|
||||
deleting = true;
|
||||
|
||||
try {
|
||||
await handleForgetSnapshot(repository.id, id);
|
||||
removeSnapshot(id);
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={`Snapshots for ${repository.name}`} {onClose}>
|
||||
<ModalBody>
|
||||
{#if query.isLoading}
|
||||
<LoadingSpinner />
|
||||
{:else if query.isError}
|
||||
<Alert color="danger">{getReadableErrorMessage(query.error)}</Alert>
|
||||
{:else if query.isSuccess}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHeading>Snapshot</TableHeading>
|
||||
<TableHeading>Created</TableHeading>
|
||||
<TableHeading></TableHeading>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{#each query.data as snapshot (snapshot.id)}
|
||||
<TableRow>
|
||||
<TableCell><code class="text-xs">{snapshot.id.slice(0, 12)}</code></TableCell>
|
||||
<TableCell>{DateTime.fromISO(snapshot.time).toRelative()}</TableCell>
|
||||
<TableCell class="flex gap-1 justify-end">
|
||||
<IconButton
|
||||
icon={mdiRestore}
|
||||
aria-label="Restore"
|
||||
size="small"
|
||||
disabled={deleting}
|
||||
onclick={() => restoreSnapshot(snapshot.id)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={mdiDeleteOutline}
|
||||
aria-label="Delete"
|
||||
size="small"
|
||||
color="danger"
|
||||
disabled={deleting}
|
||||
onclick={() => deleteSnapshot(snapshot.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
+157
-32
@@ -1,6 +1,13 @@
|
||||
<script lang="ts">
|
||||
import OnEvents from "$lib/components/util/OnEvents.svelte";
|
||||
import RelativeTime from "$lib/components/util/RelativeTime.svelte";
|
||||
import { createLogObserver } from "$lib/services/log.service.svelte";
|
||||
import { useRun, useRunEventHandler } from "$lib/services/runHistory.service";
|
||||
import { options } from "$lib/options";
|
||||
import { formatDuration } from "$lib/utils/format";
|
||||
import {
|
||||
Alert,
|
||||
FormatBytes,
|
||||
Heading,
|
||||
Modal,
|
||||
ModalBody,
|
||||
@@ -9,9 +16,8 @@
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import { DateTime } from "luxon";
|
||||
import { onDestroy } from "svelte";
|
||||
import { createLogObserver } from "$lib/services/log.service.svelte";
|
||||
import { options } from "$lib/options";
|
||||
|
||||
type Props = {
|
||||
logId: string;
|
||||
@@ -23,49 +29,168 @@
|
||||
const advanced = options.advanced;
|
||||
// svelte-ignore state_referenced_locally
|
||||
const log = createLogObserver(logId);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const runQuery = useRun(logId);
|
||||
const run = $derived(runQuery.data);
|
||||
const { onRunUpdate } = useRunEventHandler();
|
||||
|
||||
onDestroy(() => log.destroy());
|
||||
let now = $state(DateTime.now());
|
||||
const tick = setInterval(() => (now = DateTime.now()), 1000);
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(tick);
|
||||
log.destroy();
|
||||
});
|
||||
|
||||
const isRestore = $derived(run?.type === "restore");
|
||||
const running = $derived(run?.status === "incomplete");
|
||||
const duration = $derived(
|
||||
run
|
||||
? formatDuration(
|
||||
(run.end ? DateTime.fromISO(run.end) : now).toMillis() -
|
||||
DateTime.fromISO(run.start).toMillis(),
|
||||
)
|
||||
: "",
|
||||
);
|
||||
</script>
|
||||
|
||||
<Modal title="Log Output" size="giant" {onClose}>
|
||||
<OnEvents {onRunUpdate} />
|
||||
|
||||
<Modal title={isRestore ? "Restore Log" : "Backup Log"} size="giant" {onClose}>
|
||||
<ModalBody>
|
||||
<Stack gap={2}>
|
||||
{#each log.errors as error}
|
||||
<Alert color="danger">{error}</Alert>
|
||||
{/each}
|
||||
<Stack gap={5}>
|
||||
<Stack gap={1}>
|
||||
{#if !run}
|
||||
<Heading size="medium">Connecting…</Heading>
|
||||
{:else if run.status === "incomplete"}
|
||||
<Heading size="medium">
|
||||
{isRestore ? "Restoring" : "Backing up"} · {Math.round(
|
||||
log.status.progress * 100,
|
||||
)}%
|
||||
</Heading>
|
||||
{:else if run.status === "failed"}
|
||||
<Heading size="medium" color="danger">
|
||||
{isRestore ? "Restore" : "Backup"} failed after {duration}
|
||||
</Heading>
|
||||
{:else if log.errors.length > 0}
|
||||
<Heading size="medium" color="warning">
|
||||
{isRestore ? "Restored" : "Backed up"} in {duration} · {log.errors
|
||||
.length} error{log.errors.length === 1 ? "" : "s"}
|
||||
</Heading>
|
||||
{:else}
|
||||
<Heading size="medium" color="success">
|
||||
{isRestore ? "Restored" : "Backed up"} in {duration}
|
||||
</Heading>
|
||||
{/if}
|
||||
|
||||
<ProgressBar progress={log.status.progress} size="large">
|
||||
<Text
|
||||
size="small"
|
||||
class={log.status.progress > 0.5 ? "text-light" : "text-dark"}
|
||||
>
|
||||
{#if log.status.progress >= 1}
|
||||
{log.errors.length > 0 ? "Completed with errors" : "Complete"}
|
||||
{:else}
|
||||
{log.status.text}
|
||||
{#if run}
|
||||
<Text color="secondary">
|
||||
Started <RelativeTime time={run.start} />
|
||||
</Text>
|
||||
{/if}
|
||||
</Stack>
|
||||
|
||||
{#if running}
|
||||
<ProgressBar progress={log.status.progress} size="large">
|
||||
{#if log.status.text}
|
||||
<Text
|
||||
size="small"
|
||||
class={log.status.progress > 0.5 ? "text-light" : "text-dark"}
|
||||
>
|
||||
{log.status.text}
|
||||
</Text>
|
||||
{/if}
|
||||
</Text>
|
||||
</ProgressBar>
|
||||
</ProgressBar>
|
||||
{/if}
|
||||
|
||||
{#if log.status.currentFiles.length > 0}
|
||||
{#if log.errors.length > 0}
|
||||
<Stack gap={2}>
|
||||
<Alert color="danger">{log.errors[0]}</Alert>
|
||||
{#if log.errors.length > 1}
|
||||
<Scrollable class="max-h-32">
|
||||
<Stack gap={1}>
|
||||
{#each log.errors.slice(1) as error}
|
||||
<Alert color="danger">{error}</Alert>
|
||||
{/each}
|
||||
</Stack>
|
||||
</Scrollable>
|
||||
{/if}
|
||||
</Stack>
|
||||
{/if}
|
||||
|
||||
{#if log.summary}
|
||||
<Stack gap={1}>
|
||||
{#each log.status.currentFiles as file}
|
||||
<Text size="tiny" class="text-nowrap">{file}</Text>
|
||||
{#if isRestore}
|
||||
<Text>
|
||||
{(log.summary.files_restored ?? 0).toLocaleString()} restored
|
||||
{#if log.summary.files_skipped}
|
||||
· {log.summary.files_skipped.toLocaleString()} skipped
|
||||
{/if}
|
||||
{#if log.summary.files_deleted}
|
||||
· {log.summary.files_deleted.toLocaleString()} deleted
|
||||
{/if}
|
||||
</Text>
|
||||
{#if log.summary.bytes_restored !== undefined}
|
||||
<Text color="secondary">
|
||||
Restored <FormatBytes bytes={log.summary.bytes_restored} />
|
||||
{#if log.summary.total_bytes !== undefined}
|
||||
of
|
||||
<FormatBytes
|
||||
bytes={Math.max(
|
||||
log.summary.bytes_restored,
|
||||
log.summary.total_bytes,
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
</Text>
|
||||
{/if}
|
||||
{:else}
|
||||
<Text>
|
||||
{(log.summary.files_new ?? 0).toLocaleString()} new ·
|
||||
{(log.summary.files_changed ?? 0).toLocaleString()} changed ·
|
||||
{(log.summary.files_unmodified ?? 0).toLocaleString()} unchanged
|
||||
</Text>
|
||||
{#if log.summary.data_added !== undefined}
|
||||
<Text color="secondary">
|
||||
Added <FormatBytes bytes={log.summary.data_added} />
|
||||
{#if log.summary.total_bytes_processed !== undefined}
|
||||
of
|
||||
<FormatBytes
|
||||
bytes={Math.max(
|
||||
log.summary.data_added,
|
||||
log.summary.total_bytes_processed,
|
||||
)}
|
||||
/> processed
|
||||
{/if}
|
||||
</Text>
|
||||
{/if}
|
||||
{/if}
|
||||
</Stack>
|
||||
{/if}
|
||||
|
||||
{#if running}
|
||||
<Stack gap={1} class="h-20 overflow-hidden">
|
||||
{#each log.status.currentFiles.slice(0, 3) as file}
|
||||
<Text size="small" color="secondary" class="truncate" title={file}>
|
||||
{file}
|
||||
</Text>
|
||||
{/each}
|
||||
</Stack>
|
||||
{/if}
|
||||
|
||||
{#if $advanced}
|
||||
<Heading size="small">Event Log</Heading>
|
||||
<Scrollable class="h-80 overflow-x-hidden">
|
||||
<Stack gap={1}>
|
||||
{#each log.events as event}
|
||||
<Text size="tiny" class="font-mono select-all"
|
||||
>{JSON.stringify(event)}</Text
|
||||
>
|
||||
{/each}
|
||||
</Stack>
|
||||
</Scrollable>
|
||||
<Stack gap={1}>
|
||||
<Heading size="small">Event Log</Heading>
|
||||
<Scrollable class="h-80 overflow-x-hidden">
|
||||
<Stack gap={1}>
|
||||
{#each log.events as event}
|
||||
<Text size="tiny" class="font-mono select-all">
|
||||
{JSON.stringify(event)}
|
||||
</Text>
|
||||
{/each}
|
||||
</Stack>
|
||||
</Scrollable>
|
||||
</Stack>
|
||||
{/if}
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import StackList from "$lib/components/ui/StackList.svelte";
|
||||
import OnEvents from "$lib/components/util/OnEvents.svelte";
|
||||
import type { LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import {
|
||||
useRunEventHandler,
|
||||
useRunHistory,
|
||||
} from "$lib/services/runHistory.service";
|
||||
import { Text } from "@immich/ui";
|
||||
import RepositoryRunHistoryItem from "./RepositoryRunHistoryItem.svelte";
|
||||
|
||||
type Props = {
|
||||
repository: LocalRepositoryDto;
|
||||
};
|
||||
|
||||
let { repository }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const query = useRunHistory(repository.id);
|
||||
const { onRunCreate, onRunUpdate } = useRunEventHandler();
|
||||
</script>
|
||||
|
||||
<OnEvents {onRunCreate} {onRunUpdate} />
|
||||
|
||||
<StackList {query}>
|
||||
{#snippet title()}
|
||||
Recent backup attempts
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(runs)}
|
||||
{#if runs.length === 0}
|
||||
<Text class="text-center py-6" color="muted">No recent backups</Text>
|
||||
{:else}
|
||||
{#each runs.slice(0, 10) as run (run.id)}
|
||||
<RepositoryRunHistoryItem {run} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</StackList>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import StackListItem from "$lib/components/ui/StackListItem.svelte";
|
||||
import RelativeTime from "$lib/components/util/RelativeTime.svelte";
|
||||
import type { RunDto } from "$lib/fetch-client";
|
||||
import { getRunActions } from "$lib/services/runHistory.service";
|
||||
import { Icon } from "@immich/ui";
|
||||
import {
|
||||
mdiAlertCircleOutline,
|
||||
mdiCheckCircleOutline,
|
||||
mdiLoading,
|
||||
} from "@mdi/js";
|
||||
|
||||
type Props = {
|
||||
run: RunDto;
|
||||
};
|
||||
|
||||
const { run }: Props = $props();
|
||||
const { ViewLog } = $derived(getRunActions(run));
|
||||
</script>
|
||||
|
||||
<StackListItem actions={[ViewLog]}>
|
||||
{#snippet icon()}
|
||||
{#if run.status === "complete"}
|
||||
<Icon icon={mdiCheckCircleOutline} class="text-success-500" />
|
||||
{:else if run.status === "failed"}
|
||||
<Icon icon={mdiAlertCircleOutline} class="text-danger-500" />
|
||||
{:else}
|
||||
<Icon icon={mdiLoading} class="animate-spin opacity-60" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if run.status === "incomplete"}
|
||||
{run.type === "restore" ? "Restoring" : "Backing up"} · started
|
||||
<RelativeTime time={run.start} />
|
||||
{:else if run.status === "failed"}
|
||||
{run.type === "restore" ? "Restore" : "Backup"} failed
|
||||
{#if run.end}<RelativeTime time={run.end} />{/if}
|
||||
{:else}
|
||||
{run.type === "restore" ? "Restored" : "Backed up"}
|
||||
{#if run.end}<RelativeTime time={run.end} />{/if}
|
||||
{/if}
|
||||
</StackListItem>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import { Modal, ModalBody } from "@immich/ui";
|
||||
import RepositoryRunHistory from "./RepositoryRunHistory.svelte";
|
||||
|
||||
interface Props {
|
||||
repository: LocalRepositoryDto;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { repository, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal title={`Run History for ${repository.name}`} size="giant" {onClose}>
|
||||
<ModalBody>
|
||||
<RepositoryRunHistory {repository} />
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import StackList from "$lib/components/ui/StackList.svelte";
|
||||
import OnEvents from "$lib/components/util/OnEvents.svelte";
|
||||
import type { LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import {
|
||||
useSnapshotEventHandler,
|
||||
useSnapshots,
|
||||
} from "$lib/services/snapshot.service";
|
||||
import { Text } from "@immich/ui";
|
||||
import RepositorySnapshotsListItem from "./RepositorySnapshotsListItem.svelte";
|
||||
|
||||
type Props = {
|
||||
repository: LocalRepositoryDto;
|
||||
};
|
||||
|
||||
let { repository }: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const query = useSnapshots(repository.id);
|
||||
const { onRunUpdate } = useSnapshotEventHandler();
|
||||
</script>
|
||||
|
||||
<OnEvents {onRunUpdate} />
|
||||
|
||||
<StackList {query}>
|
||||
{#snippet title()}
|
||||
Snapshots
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(snapshots)}
|
||||
{#if snapshots.length === 0}
|
||||
<Text class="text-center py-6" color="muted">No backups yet</Text>
|
||||
{:else}
|
||||
{#each snapshots.slice(0, 10) as snapshot (snapshot.id)}
|
||||
<RepositorySnapshotsListItem repositoryId={repository.id} {snapshot} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</StackList>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import StackListItem from "$lib/components/ui/StackListItem.svelte";
|
||||
import RelativeTime from "$lib/components/util/RelativeTime.svelte";
|
||||
import type { SnapshotDto } from "$lib/fetch-client";
|
||||
import { getSnapshotActions } from "$lib/services/snapshot.service";
|
||||
import { FormatBytes, HStack, Text } from "@immich/ui";
|
||||
|
||||
type Props = {
|
||||
repositoryId: string;
|
||||
snapshot: SnapshotDto;
|
||||
};
|
||||
|
||||
const { repositoryId, snapshot }: Props = $props();
|
||||
const { Restore, Delete } = $derived(
|
||||
getSnapshotActions(repositoryId, snapshot),
|
||||
);
|
||||
</script>
|
||||
|
||||
<StackListItem actions={[Restore, Delete]}>
|
||||
<HStack>
|
||||
<RelativeTime time={snapshot.time} />
|
||||
|
||||
{#if snapshot.summary}
|
||||
<Text color="secondary">
|
||||
· {snapshot.summary.totalFiles.toLocaleString()} files ·
|
||||
<FormatBytes bytes={snapshot.summary.totalBytes} />
|
||||
</Text>
|
||||
|
||||
{#if snapshot.summary.filesNew > 0 || snapshot.summary.filesChanged > 0}
|
||||
<Text color="muted" size="tiny">
|
||||
{#if snapshot.summary.filesNew > 0}
|
||||
· {snapshot.summary.filesNew.toLocaleString()} new
|
||||
{/if}
|
||||
{#if snapshot.summary.filesChanged > 0}
|
||||
· {snapshot.summary.filesChanged.toLocaleString()} changed
|
||||
{/if}
|
||||
</Text>
|
||||
{/if}
|
||||
{/if}
|
||||
</HStack>
|
||||
</StackListItem>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { LocalRepositoryDto } from "$lib/fetch-client";
|
||||
import { Modal, ModalBody } from "@immich/ui";
|
||||
import RepositorySnapshotsList from "./RepositorySnapshotsList.svelte";
|
||||
|
||||
type Props = {
|
||||
repository: LocalRepositoryDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { repository, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal title={`Snapshots for ${repository.name}`} size="giant" {onClose}>
|
||||
<ModalBody>
|
||||
<RepositorySnapshotsList {repository} />
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
@@ -1,39 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { options } from "$lib/options";
|
||||
import { getReadableErrorMessage } from "$lib/utils/handle-error";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
getByteUnitString,
|
||||
Icon,
|
||||
LoadingSpinner,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import { getReadableErrorMessage } from "$lib/utils/handle-error";
|
||||
import { mdiBackupRestore } from "@mdi/js";
|
||||
import { options } from "$lib/options";
|
||||
|
||||
import BackupHealth from "./BackupHealth.svelte";
|
||||
import BackupStats from "./BackupStats.svelte";
|
||||
import ActiveJobs from "./ActiveJobs.svelte";
|
||||
import ImmichIntegrationCard from "./ImmichIntegrationCard.svelte";
|
||||
import RecentBackups from "./RecentBackups.svelte";
|
||||
import StackedBarChart from "./visualisations/StackedBarChart.svelte";
|
||||
import HeatMap from "./visualisations/HeatMap.svelte";
|
||||
import OnEvents from "../util/OnEvents.svelte";
|
||||
import {
|
||||
useRepositories,
|
||||
useRepositoryEventHandler,
|
||||
} from "$lib/services/repository.service";
|
||||
import {
|
||||
useIntegrations,
|
||||
useIntegrationEventHandler,
|
||||
} from "$lib/services/integrations.service";
|
||||
import {
|
||||
useSchedules,
|
||||
useScheduleEventHandler,
|
||||
} from "$lib/services/schedule.service";
|
||||
import OnEvents from "../util/OnEvents.svelte";
|
||||
import ActiveJobs from "./ActiveJobs.svelte";
|
||||
import BackupHealth from "./BackupHealth.svelte";
|
||||
import BackupStats from "./BackupStats.svelte";
|
||||
import RecentBackups from "./RecentBackups.svelte";
|
||||
|
||||
type Props = {
|
||||
onNavigate?: (route: string) => void;
|
||||
@@ -44,20 +32,11 @@
|
||||
const { onNavigate }: Props = $props();
|
||||
|
||||
const query = useRepositories();
|
||||
const integrationsQuery = useIntegrations();
|
||||
const schedulesQuery = useSchedules();
|
||||
const { onRepositoryCreate, onRepositoryUpdate } =
|
||||
useRepositoryEventHandler();
|
||||
const { onScheduleUpdate } = useScheduleEventHandler();
|
||||
const { onIntegrationUpdate } = useIntegrationEventHandler();
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
{onRepositoryCreate}
|
||||
{onRepositoryUpdate}
|
||||
{onScheduleUpdate}
|
||||
{onIntegrationUpdate}
|
||||
/>
|
||||
<OnEvents {onRepositoryCreate} {onRepositoryUpdate} />
|
||||
|
||||
{#if query.isLoading}
|
||||
<LoadingSpinner />
|
||||
@@ -84,21 +63,6 @@
|
||||
</CardBody>
|
||||
</Card>
|
||||
{:else if query.isSuccess}
|
||||
{#if integrationsQuery.isSuccess && integrationsQuery.data.immichIntegration}
|
||||
<ImmichIntegrationCard
|
||||
schedule={schedulesQuery.data?.find(
|
||||
(schedule) =>
|
||||
schedule.id === integrationsQuery.data!.immichIntegration!.scheduleId,
|
||||
)}
|
||||
metrics={query.data.find(
|
||||
(repository) =>
|
||||
repository.id === integrationsQuery.data!.immichIntegration!.id,
|
||||
)?.metrics}
|
||||
/>
|
||||
{:else if integrationsQuery.isSuccess && integrationsQuery.data.immichState}
|
||||
<ImmichIntegrationCard unconfigured />
|
||||
{/if}
|
||||
|
||||
<BackupHealth repositories={query.data} />
|
||||
|
||||
<BackupStats repositories={query.data} />
|
||||
@@ -109,119 +73,4 @@
|
||||
repositories={query.data}
|
||||
onNavigate={onNavigate ? () => onNavigate("backups") : undefined}
|
||||
/>
|
||||
|
||||
{#if $advanced}
|
||||
<Alert>Mock data provided for advanced graphs below.</Alert>
|
||||
|
||||
<Stack direction="row">
|
||||
<Card class="flex-1">
|
||||
<CardBody>
|
||||
<Stack>
|
||||
<Text size="large">Storage Breakdown</Text>
|
||||
<StackedBarChart
|
||||
categoryKey="day"
|
||||
keys={["Latest Snapshots", "Older Snapshots"]}
|
||||
colours={[
|
||||
"var(--immich-ui-primary-500)",
|
||||
"var(--immich-ui-primary-300)",
|
||||
]}
|
||||
formatValue={(v) => getByteUnitString(v, undefined, 1)}
|
||||
data={[
|
||||
{
|
||||
day: "Mon",
|
||||
"Latest Snapshots": 1_200_000_000,
|
||||
"Older Snapshots": 2_400_000_000,
|
||||
},
|
||||
{
|
||||
day: "Tue",
|
||||
"Latest Snapshots": 1_400_000_000,
|
||||
"Older Snapshots": 2_800_000_000,
|
||||
},
|
||||
{
|
||||
day: "Wed",
|
||||
"Latest Snapshots": 1_100_000_000,
|
||||
"Older Snapshots": 2_600_000_000,
|
||||
},
|
||||
{
|
||||
day: "Thu",
|
||||
"Latest Snapshots": 1_500_000_000,
|
||||
"Older Snapshots": 3_000_000_000,
|
||||
},
|
||||
{
|
||||
day: "Fri",
|
||||
"Latest Snapshots": 1_300_000_000,
|
||||
"Older Snapshots": 2_500_000_000,
|
||||
},
|
||||
{
|
||||
day: "Sat",
|
||||
"Latest Snapshots": 800_000_000,
|
||||
"Older Snapshots": 1_900_000_000,
|
||||
},
|
||||
{
|
||||
day: "Sun",
|
||||
"Latest Snapshots": 700_000_000,
|
||||
"Older Snapshots": 1_700_000_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card class="flex-1">
|
||||
<CardBody>
|
||||
<Stack>
|
||||
<Text size="large">Transfer Bandwidth</Text>
|
||||
<StackedBarChart
|
||||
categoryKey="day"
|
||||
keys={["Upload", "Download"]}
|
||||
colours={[
|
||||
"var(--immich-ui-info-500)",
|
||||
"var(--immich-ui-info-300)",
|
||||
]}
|
||||
formatValue={(v) => getByteUnitString(v, undefined, 1)}
|
||||
data={[
|
||||
{ day: "Mon", Upload: 3_200_000_000, Download: 100_000_000 },
|
||||
{ day: "Tue", Upload: 2_800_000_000, Download: 400_000_000 },
|
||||
{ day: "Wed", Upload: 4_100_000_000, Download: 200_000_000 },
|
||||
{ day: "Thu", Upload: 3_500_000_000, Download: 1_800_000_000 },
|
||||
{ day: "Fri", Upload: 2_900_000_000, Download: 100_000_000 },
|
||||
{ day: "Sat", Upload: 1_200_000_000, Download: 0 },
|
||||
{ day: "Sun", Upload: 1_000_000_000, Download: 0 },
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{@const heatMapRows = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]}
|
||||
{@const heatMapCols = Array.from({ length: 24 }, (_, i) => {
|
||||
if (i === 0) return "12a";
|
||||
if (i < 12) return `${i}a`;
|
||||
if (i === 12) return "12p";
|
||||
return `${i - 12}p`;
|
||||
})}
|
||||
{@const heatMapData = heatMapRows.flatMap((day) =>
|
||||
heatMapCols.map((hour) => ({
|
||||
row: day,
|
||||
col: hour,
|
||||
value: Math.round(Math.exp(Math.random() * 5)),
|
||||
})),
|
||||
)}
|
||||
<Card class="flex-1">
|
||||
<CardBody>
|
||||
<Stack>
|
||||
<Text size="large">Job Activity</Text>
|
||||
<HeatMap
|
||||
data={heatMapData}
|
||||
rows={heatMapRows}
|
||||
cols={heatMapCols}
|
||||
colours={[
|
||||
"var(--immich-ui-light-200)",
|
||||
"var(--immich-ui-primary-400)",
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Stack>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
HStack,
|
||||
immichLogo,
|
||||
modalManager,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import type { RepositoryMetricsDto, ScheduleDto } from "$lib/fetch-client";
|
||||
import {
|
||||
handlePauseSchedule,
|
||||
handleResumeSchedule,
|
||||
} from "$lib/services/schedule.service";
|
||||
import CreateImmichBackup from "../backups/dialogs/ConfigureImmichBackup.svelte";
|
||||
import RelativeTime from "../util/RelativeTime.svelte";
|
||||
|
||||
type Props = {
|
||||
schedule?: ScheduleDto;
|
||||
metrics?: RepositoryMetricsDto;
|
||||
unconfigured?: boolean;
|
||||
};
|
||||
|
||||
const { schedule, metrics, unconfigured = false }: Props = $props();
|
||||
|
||||
const onConfigure = () => {
|
||||
modalManager.open(CreateImmichBackup, {});
|
||||
};
|
||||
|
||||
const onTogglePause = () => {
|
||||
if (schedule!.paused) {
|
||||
handleResumeSchedule(schedule!.id, schedule!.name);
|
||||
} else {
|
||||
handlePauseSchedule(schedule!.id, schedule!.name);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div class="flex items-center gap-4">
|
||||
<img src={immichLogo} alt="Immich" class="h-12 w-12" />
|
||||
|
||||
<div class="flex-1">
|
||||
<Text size="large">Immich Backup</Text>
|
||||
{#if unconfigured}
|
||||
<Text color="secondary" class="text-sm">
|
||||
Set up Immich automatic backups
|
||||
</Text>
|
||||
{:else if metrics?.lastBackup && (!metrics.lastSuccessfulBackup || +new Date(metrics.lastBackup) > +new Date(metrics.lastSuccessfulBackup))}
|
||||
<Text color="danger" class="text-sm">
|
||||
Failed <RelativeTime time={metrics.lastBackup} />
|
||||
</Text>
|
||||
{:else if metrics?.lastSuccessfulBackup}
|
||||
<Text color="success" class="text-sm">
|
||||
Successful <RelativeTime time={metrics.lastSuccessfulBackup} />
|
||||
</Text>
|
||||
{:else}
|
||||
<Text color="secondary" class="text-sm">Never run</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<HStack gap={2}>
|
||||
{#if unconfigured}
|
||||
<Button size="small" color="primary" onclick={onConfigure}
|
||||
>Set Up</Button
|
||||
>
|
||||
{:else}
|
||||
<Button size="small" variant="outline" onclick={onConfigure}
|
||||
>Configure</Button
|
||||
>
|
||||
{#if schedule}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outline"
|
||||
color={schedule.paused ? "primary" : "danger"}
|
||||
onclick={onTogglePause}
|
||||
>
|
||||
{schedule.paused ? "Resume" : "Pause"}
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</HStack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
-173
@@ -1,173 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as d3 from "d3";
|
||||
|
||||
type Datum = {
|
||||
row: string;
|
||||
col: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: Datum[];
|
||||
rows: string[];
|
||||
cols: string[];
|
||||
colours: [string, string];
|
||||
};
|
||||
|
||||
const { data, rows, cols, colours }: Props = $props();
|
||||
|
||||
const width = 400;
|
||||
const height = 300;
|
||||
const marginTop = 10;
|
||||
const marginRight = 10;
|
||||
const marginBottom = 30;
|
||||
const marginLeft = 35;
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const xScale = d3
|
||||
.scaleBand()
|
||||
.domain(cols)
|
||||
.range([marginLeft, width - marginRight])
|
||||
.padding(0.1);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const yScale = d3
|
||||
.scaleBand()
|
||||
.domain(rows)
|
||||
.range([marginTop, height - marginBottom])
|
||||
.padding(0.1);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const maxValue = d3.max(data, (d) => d.value) ?? 1;
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const valueMap = new Map(data.map((d) => [`${d.row}:${d.col}`, d.value]));
|
||||
|
||||
function intensity(value: number): number {
|
||||
return maxValue > 0 ? Math.round((value / maxValue) * 100) : 0;
|
||||
}
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const labelInterval = Math.max(1, Math.ceil(cols.length / 8));
|
||||
|
||||
let hovered = $state<{ row: string; col: string } | null>(null);
|
||||
let tooltip = $state<{
|
||||
x: number;
|
||||
y: number;
|
||||
row: string;
|
||||
col: string;
|
||||
value: number;
|
||||
} | null>(null);
|
||||
|
||||
function onCellEnter(row: string, col: string, value: number, e: MouseEvent) {
|
||||
hovered = { row, col };
|
||||
tooltip = { x: e.clientX, y: e.clientY, row, col, value };
|
||||
}
|
||||
|
||||
function onCellMove(e: MouseEvent) {
|
||||
if (!tooltip) return;
|
||||
tooltip = { ...tooltip, x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
function onCellLeave() {
|
||||
hovered = null;
|
||||
tooltip = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<svg
|
||||
viewBox="0 0 {width} {height}"
|
||||
style:max-width="100%"
|
||||
style:height="auto"
|
||||
>
|
||||
{#each rows as row}
|
||||
{#each cols as col}
|
||||
{@const value = valueMap.get(`${row}:${col}`) ?? 0}
|
||||
{@const isHovered = hovered?.row === row && hovered?.col === col}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<rect
|
||||
x={xScale(col)}
|
||||
y={yScale(row)}
|
||||
width={xScale.bandwidth()}
|
||||
height={yScale.bandwidth()}
|
||||
rx={2}
|
||||
ry={2}
|
||||
style="fill: color-mix(in oklch, {colours[1]} {intensity(
|
||||
value,
|
||||
)}%, {colours[0]}); transition: opacity 150ms;"
|
||||
opacity={hovered === null || isHovered ? 1 : 0.4}
|
||||
stroke={isHovered ? "currentColor" : "none"}
|
||||
stroke-width={isHovered ? 1.5 : 0}
|
||||
onmouseenter={(e) => onCellEnter(row, col, value, e)}
|
||||
onmousemove={onCellMove}
|
||||
onmouseleave={onCellLeave}
|
||||
/>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
<g>
|
||||
{#each rows as row}
|
||||
<text
|
||||
fill="currentColor"
|
||||
text-anchor="end"
|
||||
dominant-baseline="central"
|
||||
x={marginLeft - 6}
|
||||
y={(yScale(row) ?? 0) + yScale.bandwidth() / 2}
|
||||
font-size="11"
|
||||
>
|
||||
{row}
|
||||
</text>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g transform="translate(0,{height - marginBottom})">
|
||||
{#each cols as col, i}
|
||||
{#if i % labelInterval === 0}
|
||||
<text
|
||||
fill="currentColor"
|
||||
text-anchor="middle"
|
||||
x={(xScale(col) ?? 0) + xScale.bandwidth() / 2}
|
||||
y={18}
|
||||
font-size="11"
|
||||
>
|
||||
{col}
|
||||
</text>
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{#if tooltip}
|
||||
<div
|
||||
style="
|
||||
position: fixed;
|
||||
left: {tooltip.x + 12}px;
|
||||
top: {tooltip.y - 8}px;
|
||||
background: var(--immich-ui-dark);
|
||||
color: var(--immich-ui-light);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
"
|
||||
>
|
||||
<div style="font-weight: 600;">{tooltip.row} {tooltip.col}</div>
|
||||
<div>{tooltip.value} {tooltip.value === 1 ? "job" : "jobs"}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="display: flex; align-items: center; justify-content: flex-end; gap: 4px; margin-top: 4px;"
|
||||
>
|
||||
<span style="font-size: 0.8rem;">Less</span>
|
||||
{#each [0, 25, 50, 75, 100] as pct}
|
||||
<span
|
||||
style="display: inline-block; width: 12px; height: 12px; border-radius: 2px; background: color-mix(in oklch, {colours[1]} {pct}%, {colours[0]});"
|
||||
></span>
|
||||
{/each}
|
||||
<span style="font-size: 0.8rem;">More</span>
|
||||
</div>
|
||||
-200
@@ -1,200 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as d3 from "d3";
|
||||
|
||||
type Datum = Record<string, string | number>;
|
||||
|
||||
type Props = {
|
||||
data: Datum[];
|
||||
categoryKey: string;
|
||||
keys: string[];
|
||||
colours: string[];
|
||||
formatValue?: (value: number) => string;
|
||||
};
|
||||
|
||||
const { data, categoryKey, keys, colours, formatValue }: Props = $props();
|
||||
|
||||
const fmt = (v: number) =>
|
||||
formatValue ? formatValue(v) : v.toLocaleString("en-US");
|
||||
|
||||
const width = 400;
|
||||
const height = 300;
|
||||
const marginTop = 20;
|
||||
const marginRight = 10;
|
||||
const marginBottom = 40;
|
||||
// svelte-ignore state_referenced_locally
|
||||
const series = d3
|
||||
.stack<Datum>()
|
||||
.keys(keys)
|
||||
.value((d, key) => (d[key] as number) ?? 0)(data);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const categories = data.map((d) => String(d[categoryKey]));
|
||||
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, d3.max(series, (s) => d3.max(s, (d) => d[1])) ?? 0])
|
||||
.nice()
|
||||
.range([height - marginBottom, marginTop]);
|
||||
|
||||
const ticks = yScale.ticks(5);
|
||||
const tickLabels = ticks.map((t) => fmt(t));
|
||||
|
||||
function measureText(labels: string[]): number {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return Math.max(...labels.map((l) => l.length)) * 9;
|
||||
ctx.font = "16px sans-serif";
|
||||
return Math.max(...labels.map((l) => ctx.measureText(l).width));
|
||||
}
|
||||
|
||||
const marginLeft = Math.ceil(measureText(tickLabels)) + 12;
|
||||
|
||||
const xScale = d3
|
||||
.scaleBand()
|
||||
.domain(categories)
|
||||
.range([marginLeft, width - marginRight])
|
||||
.padding(0.5);
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const colourScale = d3.scaleOrdinal<string>().domain(keys).range(colours);
|
||||
|
||||
let hoveredCat = $state<string | null>(null);
|
||||
let tooltip = $state<{
|
||||
x: number;
|
||||
y: number;
|
||||
cat: string;
|
||||
values: { key: string; value: number }[];
|
||||
} | null>(null);
|
||||
|
||||
function onBarEnter(cat: string, e: MouseEvent) {
|
||||
hoveredCat = cat;
|
||||
const values = keys.map((key) => {
|
||||
const datum = data.find((d) => String(d[categoryKey]) === cat);
|
||||
return { key, value: (datum?.[key] as number) ?? 0 };
|
||||
});
|
||||
tooltip = { x: e.clientX, y: e.clientY, cat, values };
|
||||
}
|
||||
|
||||
function onBarMove(e: MouseEvent) {
|
||||
if (!tooltip) return;
|
||||
tooltip = { ...tooltip, x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
function onBarLeave() {
|
||||
hoveredCat = null;
|
||||
tooltip = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<svg
|
||||
viewBox="0 0 {width} {height}"
|
||||
style:max-width="100%"
|
||||
style:height="auto"
|
||||
>
|
||||
{#each series as layer}
|
||||
<g fill={colourScale(layer.key)}>
|
||||
{#each layer as d}
|
||||
{@const cat = String(d.data[categoryKey])}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<rect
|
||||
x={xScale(cat)}
|
||||
y={yScale(d[1])}
|
||||
height={yScale(d[0]) - yScale(d[1])}
|
||||
width={xScale.bandwidth()}
|
||||
opacity={hoveredCat === null || hoveredCat === cat ? 1 : 0.3}
|
||||
style="transition: opacity 150ms;"
|
||||
onmouseenter={(e) => onBarEnter(cat, e)}
|
||||
onmousemove={onBarMove}
|
||||
onmouseleave={onBarLeave}
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
<g transform="translate(0,{height - marginBottom})">
|
||||
<line stroke="currentColor" x1={marginLeft} x2={width - marginRight} />
|
||||
|
||||
{#each categories as cat}
|
||||
<text
|
||||
fill="currentColor"
|
||||
text-anchor="middle"
|
||||
x={(xScale(cat) ?? 0) + xScale.bandwidth() / 2}
|
||||
y={22}
|
||||
>
|
||||
{cat}
|
||||
</text>
|
||||
{/each}
|
||||
</g>
|
||||
|
||||
<g>
|
||||
{#each ticks as tick}
|
||||
{#if tick !== 0}
|
||||
<line
|
||||
stroke="currentColor"
|
||||
x1={marginLeft}
|
||||
x2={marginLeft - 6}
|
||||
y1={yScale(tick)}
|
||||
y2={yScale(tick)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<text
|
||||
fill="currentColor"
|
||||
text-anchor="end"
|
||||
dominant-baseline="middle"
|
||||
x={marginLeft - 9}
|
||||
y={yScale(tick)}
|
||||
>
|
||||
{fmt(tick)}
|
||||
</text>
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{#if tooltip}
|
||||
<div
|
||||
class="tooltip"
|
||||
style="
|
||||
position: fixed;
|
||||
left: {tooltip.x + 12}px;
|
||||
top: {tooltip.y - 8}px;
|
||||
background: var(--immich-ui-dark);
|
||||
color: var(--immich-ui-light);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
"
|
||||
>
|
||||
<div style="font-weight: 600; margin-bottom: 2px;">{tooltip.cat}</div>
|
||||
{#each tooltip.values as v}
|
||||
<div style="display: flex; align-items: center; gap: 6px;">
|
||||
<span
|
||||
style="width: 8px; height: 8px; border-radius: 2px; background: {colourScale(
|
||||
v.key,
|
||||
)};"
|
||||
></span>
|
||||
{v.key}: {fmt(v.value)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="display: flex; justify-content: center; gap: 16px; margin-top: 4px;"
|
||||
>
|
||||
{#each keys as key}
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<span
|
||||
style="display: inline-block; width: 12px; height: 12px; background: {colourScale(
|
||||
key,
|
||||
)}; border-radius: 2px;"
|
||||
></span>
|
||||
<span style="font-size: 0.8rem;">{key}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import ImmichManageBackup from "./ImmichManageBackup.svelte";
|
||||
import ImmichOnboardingSetupFlow from "./ImmichOnboardingSetupFlow.svelte";
|
||||
|
||||
type Props = {
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
const { onExit }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ImmichOnboardingSetupFlow {onExit}>
|
||||
<ImmichManageBackup />
|
||||
</ImmichOnboardingSetupFlow>
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
<script lang="ts">
|
||||
import Suspense from "$lib/components/util/Suspense.svelte";
|
||||
import * as sdk from "$lib/fetch-client";
|
||||
import { useIntegrations } from "$lib/services/integrations.service";
|
||||
import { useSchedules } from "$lib/services/schedule.service";
|
||||
import { handleError } from "$lib/utils/handle-error";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Field,
|
||||
FormModal,
|
||||
Heading,
|
||||
HStack,
|
||||
Input,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import validateCron from "cron-validate";
|
||||
import cronstrue from "cronstrue";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
interface Props {
|
||||
onFinish?: () => void;
|
||||
onCancel?: () => void;
|
||||
onClose?: () => void;
|
||||
backendId?: string;
|
||||
}
|
||||
|
||||
let { onFinish, onCancel, onClose }: Props = $props();
|
||||
|
||||
const query = useIntegrations();
|
||||
const schedulesQuery = useSchedules();
|
||||
|
||||
let name = $state("Immich");
|
||||
let worm = $state(false);
|
||||
let backupConfiguration = $state(true);
|
||||
let librariesMode = $state<"all" | "none" | "some">("all");
|
||||
|
||||
let cron = $state("0 3 * * *");
|
||||
let scheduleMode = $state<"daily" | "custom">("daily");
|
||||
let scheduleHour = $state(3);
|
||||
let scheduleMinute = $state(0);
|
||||
|
||||
const selectedFolders = new SvelteSet<string>();
|
||||
const selectedLibraries = new SvelteSet<string>();
|
||||
|
||||
type FolderItem = { label: string; description?: string; folders: string[] };
|
||||
|
||||
let populated = false;
|
||||
|
||||
$effect(() => {
|
||||
if (populated || !query.data?.immichState) {
|
||||
return;
|
||||
}
|
||||
populated = true;
|
||||
|
||||
const integration = query.data.immichIntegration;
|
||||
if (integration) {
|
||||
const config = integration.configuration;
|
||||
backupConfiguration = config.backupConfiguration;
|
||||
|
||||
if (config.libraries === "all") {
|
||||
librariesMode = "all";
|
||||
} else if (config.libraries.length === 0) {
|
||||
librariesMode = "none";
|
||||
} else {
|
||||
librariesMode = "some";
|
||||
|
||||
for (const id of config.libraries) {
|
||||
selectedLibraries.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const folder of config.dataFolders) {
|
||||
selectedFolders.add(folder);
|
||||
}
|
||||
|
||||
const schedule = schedulesQuery.data?.find(
|
||||
(schedule) => schedule.id === integration.scheduleId,
|
||||
);
|
||||
|
||||
if (schedule) {
|
||||
cron = schedule.cron;
|
||||
|
||||
const match = /(\d+) (\d+) \* \* \*/.exec(cron);
|
||||
if (match) {
|
||||
scheduleMode = "daily";
|
||||
scheduleHour = parseInt(match[2]);
|
||||
scheduleMinute = parseInt(match[1]);
|
||||
} else {
|
||||
scheduleMode = "custom";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const folder of query.data.immichState.dataFolders) {
|
||||
selectedFolders.add(folder);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const effectiveCron = $derived(
|
||||
scheduleMode === "daily" ? `${scheduleMinute} ${scheduleHour} * * *` : cron,
|
||||
);
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
await sdk.configureImmichIntegration({
|
||||
name,
|
||||
worm,
|
||||
cron: effectiveCron,
|
||||
dataFolders: [...selectedFolders],
|
||||
backupConfiguration,
|
||||
libraries:
|
||||
librariesMode === "all"
|
||||
? "all"
|
||||
: librariesMode === "none"
|
||||
? []
|
||||
: [...selectedLibraries],
|
||||
});
|
||||
|
||||
onFinish?.();
|
||||
onClose?.();
|
||||
} catch (error) {
|
||||
handleError(error, "Failed to save backup settings");
|
||||
}
|
||||
};
|
||||
|
||||
function applicableFolders(item: FolderItem, available: string[]): string[] {
|
||||
return item.folders.filter((f) => available.includes(f));
|
||||
}
|
||||
|
||||
function isItemChecked(item: FolderItem, available: string[]): boolean {
|
||||
const folders = applicableFolders(item, available);
|
||||
return folders.length > 0 && folders.every((f) => selectedFolders.has(f));
|
||||
}
|
||||
|
||||
function toggleItem(item: FolderItem, available: string[]) {
|
||||
const folders = applicableFolders(item, available);
|
||||
const allOn = folders.every((f) => selectedFolders.has(f));
|
||||
for (const f of folders) {
|
||||
if (allOn) {
|
||||
selectedFolders.delete(f);
|
||||
} else {
|
||||
selectedFolders.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleLibrary(id: string) {
|
||||
if (selectedLibraries.has(id)) {
|
||||
selectedLibraries.delete(id);
|
||||
} else {
|
||||
selectedLibraries.add(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
title="Backup settings"
|
||||
size="large"
|
||||
disabled={!validateCron(effectiveCron) || name.length === 0}
|
||||
onClose={() => {
|
||||
onCancel?.();
|
||||
onClose?.();
|
||||
}}
|
||||
{onSubmit}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<Field label="Name">
|
||||
<Input bind:value={name} />
|
||||
</Field>
|
||||
|
||||
<Suspense {query}>
|
||||
{#snippet children({ immichState: immich })}
|
||||
<Stack gap={2}>
|
||||
<Heading size="tiny">What to back up</Heading>
|
||||
|
||||
{#each [{ label: "Photos and videos", description: "Your media uploaded directly to Immich.", folders: ["upload", "profile", "library"], recommended: true }, { label: "Database backups", description: "Albums, faces, and metadata. You'll need this to restore.", folders: ["backups"], recommended: true }, { label: "Thumbnails and previews", description: "Generated photo previews, can be recreated later.", folders: ["thumbs"], regenerate: true }, { label: "Encoded videos", description: "Generated video previews, can be recreated later.", folders: ["encoded-video"], regenerate: true }] as item (item.label)}
|
||||
<label class="select-none">
|
||||
<Stack direction="row">
|
||||
<Checkbox
|
||||
checked={isItemChecked(item, immich!.dataFolders)}
|
||||
onCheckedChange={() => toggleItem(item, immich!.dataFolders)}
|
||||
/>
|
||||
<Stack gap={0}>
|
||||
<Text>{item.label}</Text>
|
||||
{#if item.description}
|
||||
<Text size="small" color="secondary"
|
||||
>{item.description}</Text
|
||||
>
|
||||
{/if}
|
||||
{#if item.recommended && !isItemChecked(item, immich!.dataFolders)}
|
||||
<Text size="small" color="danger"
|
||||
>It is highly recommended you back this up!</Text
|
||||
>
|
||||
{/if}
|
||||
{#if item.regenerate && !isItemChecked(item, immich!.dataFolders)}
|
||||
<Text size="small" color="warning"
|
||||
>When restoring, you will see broken previews, use the
|
||||
admin panel to regenerate them.</Text
|
||||
>
|
||||
{/if}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</label>
|
||||
{/each}
|
||||
|
||||
<label class="select-none">
|
||||
<Stack direction="row">
|
||||
<Checkbox bind:checked={backupConfiguration} />
|
||||
<Stack gap={0}>
|
||||
<Text>Backup configuration</Text>
|
||||
<Text size="small" color="secondary"
|
||||
>Saves these settings too.</Text
|
||||
>
|
||||
{#if !backupConfiguration}
|
||||
<Text size="small" color="warning"
|
||||
>You will need to reconfigure backups from scratch after
|
||||
restoring.</Text
|
||||
>
|
||||
{/if}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</label>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Stack gap={0}>
|
||||
<Heading size="tiny">External Libraries</Heading>
|
||||
<Text size="small" color="secondary">
|
||||
External libraries stored outside of Immich can also be included
|
||||
in your backup. <br /> By default, all existing and future external
|
||||
libraries will be included in your backup.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<HStack fullWidth>
|
||||
{#each [{ id: "all" as const, label: "All" }, { id: "none" as const, label: "None" }, { id: "some" as const, label: "Pick" }] as option (option.id)}
|
||||
<Button
|
||||
fullWidth
|
||||
size="small"
|
||||
variant={librariesMode === option.id ? "filled" : "outline"}
|
||||
color={librariesMode === option.id ? "primary" : "secondary"}
|
||||
onclick={() => (librariesMode = option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</HStack>
|
||||
|
||||
{#if librariesMode === "all"}
|
||||
{#if immich!.libraries.length !== 0}
|
||||
<Text size="small" color="secondary"
|
||||
>Libraries included: {immich!.libraries
|
||||
.map((item) => item.name)
|
||||
.join(", ")}</Text
|
||||
>
|
||||
{/if}
|
||||
{:else if librariesMode === "some"}
|
||||
{#if immich!.libraries.length === 0}
|
||||
<Text size="small" color="secondary"
|
||||
>No external libraries found.</Text
|
||||
>
|
||||
{:else}
|
||||
<Stack gap={1}>
|
||||
{#each immich!.libraries as library (library.id)}
|
||||
<label class="flex select-none items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedLibraries.has(library.id)}
|
||||
onCheckedChange={() => toggleLibrary(library.id)}
|
||||
/>
|
||||
<Text>{library.name}</Text>
|
||||
</label>
|
||||
{/each}
|
||||
</Stack>
|
||||
{/if}
|
||||
{/if}
|
||||
</Stack>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Heading size="tiny">Schedule</Heading>
|
||||
|
||||
<HStack fullWidth>
|
||||
{#each [{ id: "daily" as const, label: "Every day" }, { id: "custom" as const, label: "Custom cron expression" }] as option (option.id)}
|
||||
<Button
|
||||
fullWidth
|
||||
size="small"
|
||||
variant={scheduleMode === option.id ? "filled" : "outline"}
|
||||
color={scheduleMode === option.id ? "primary" : "secondary"}
|
||||
onclick={() => (scheduleMode = option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</HStack>
|
||||
|
||||
{#if scheduleMode === "daily"}
|
||||
<Field label="Time">
|
||||
<Input
|
||||
type="time"
|
||||
value={`${String(scheduleHour).padStart(2, "0")}:${String(scheduleMinute).padStart(2, "0")}`}
|
||||
oninput={(e) => {
|
||||
const [h, m] = (
|
||||
e.currentTarget as HTMLInputElement
|
||||
).value.split(":");
|
||||
scheduleHour = Number(h);
|
||||
scheduleMinute = Number(m);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
{:else}
|
||||
<Field label="Cron expression">
|
||||
<Input bind:value={cron} placeholder="0 3 * * *" />
|
||||
</Field>
|
||||
|
||||
<Text size="small" color="secondary"
|
||||
>Your backups will run <span class="lowercase"
|
||||
>{cronstrue.toString(cron, {
|
||||
verbose: true,
|
||||
})}</span
|
||||
></Text
|
||||
>
|
||||
{/if}
|
||||
</Stack>
|
||||
{/snippet}
|
||||
</Suspense>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Heading size="tiny">Advanced</Heading>
|
||||
<Field
|
||||
label="Write-only"
|
||||
description="Once written, files can't be removed."
|
||||
>
|
||||
<Switch bind:checked={worm} />
|
||||
</Field>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</FormModal>
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { useConfigureImmichDefaults } from "$lib/services/integrations.service";
|
||||
import {
|
||||
Button,
|
||||
HStack,
|
||||
Icon,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import {
|
||||
mdiClockOutline,
|
||||
mdiFolderMultipleOutline,
|
||||
mdiImageMultipleOutline,
|
||||
} from "@mdi/js";
|
||||
|
||||
type Props = {
|
||||
onCustomize: () => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const { onCustomize, onConfirm, onCancel }: Props = $props();
|
||||
|
||||
const mutation = useConfigureImmichDefaults();
|
||||
|
||||
const onCreate = () =>
|
||||
mutation.mutate(undefined, { onSuccess: () => onConfirm() });
|
||||
</script>
|
||||
|
||||
<Modal title="Ready to back up Immich" size="small" onClose={onCancel}>
|
||||
<ModalBody>
|
||||
<Stack gap={3}>
|
||||
<Text>
|
||||
We'll create a backup with sensible defaults. You can change anything
|
||||
later from the Immich backups page.
|
||||
</Text>
|
||||
|
||||
<Stack>
|
||||
<HStack>
|
||||
<Icon
|
||||
icon={mdiImageMultipleOutline}
|
||||
class="shrink-0 place-self-start mt-1"
|
||||
/>
|
||||
<Text>Photos, videos, database, and configuration</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Icon
|
||||
icon={mdiFolderMultipleOutline}
|
||||
class="shrink-0 place-self-start mt-1"
|
||||
/>
|
||||
<Text>All external libraries</Text>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Icon
|
||||
icon={mdiClockOutline}
|
||||
class="shrink-0 place-self-start mt-1"
|
||||
/>
|
||||
<Text>Runs every day at 3:00 AM</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={onCustomize}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Customize
|
||||
</Button>
|
||||
<Button onclick={onCreate} loading={mutation.isPending}>
|
||||
Create backup
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import BackendsList from "$lib/components/backends/BackendsList.svelte";
|
||||
import RepositoryRunHistory from "$lib/components/backups/run-history/RepositoryRunHistory.svelte";
|
||||
import RepositorySnapshotsList from "$lib/components/backups/snapshots-list/RepositorySnapshotsList.svelte";
|
||||
import PageLayout from "$lib/components/ui/PageLayout.svelte";
|
||||
import OnEvents from "$lib/components/util/OnEvents.svelte";
|
||||
import { getBackupPageActions } from "$lib/services/immich.integration.service";
|
||||
import {
|
||||
useIntegrationEventHandler,
|
||||
useIntegrations,
|
||||
} from "$lib/services/integrations.service";
|
||||
import {
|
||||
useRepositories,
|
||||
useRepositoryEventHandler,
|
||||
} from "$lib/services/repository.service";
|
||||
import {
|
||||
useScheduleEventHandler,
|
||||
useSchedules,
|
||||
} from "$lib/services/schedule.service";
|
||||
import { Container, Stack } from "@immich/ui";
|
||||
import ImmichManageBackupOverview from "./ImmichManageBackupOverview.svelte";
|
||||
|
||||
const schedules = useSchedules();
|
||||
const repositories = useRepositories();
|
||||
const integrations = useIntegrations();
|
||||
|
||||
const { onScheduleCreate, onScheduleUpdate, onScheduleDelete } =
|
||||
useScheduleEventHandler();
|
||||
const { onRepositoryCreate, onRepositoryUpdate, onRepositoryDelete } =
|
||||
useRepositoryEventHandler();
|
||||
const { onIntegrationUpdate } = useIntegrationEventHandler();
|
||||
|
||||
const schedule = $derived(
|
||||
integrations.data?.immichIntegration
|
||||
? schedules.data?.find(
|
||||
(schedule) =>
|
||||
schedule.id === integrations.data.immichIntegration!.scheduleId,
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const repository = $derived(
|
||||
integrations.data?.immichIntegration
|
||||
? repositories.data?.find(
|
||||
(repository) =>
|
||||
repository.id === integrations.data.immichIntegration!.id,
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const { ViewRecoveryKey, Configure, BackUpNow } = $derived(
|
||||
getBackupPageActions(repository?.id),
|
||||
);
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
{onScheduleCreate}
|
||||
{onScheduleUpdate}
|
||||
{onScheduleDelete}
|
||||
{onRepositoryCreate}
|
||||
{onRepositoryUpdate}
|
||||
{onRepositoryDelete}
|
||||
{onIntegrationUpdate}
|
||||
/>
|
||||
|
||||
<PageLayout title="Backups" actions={[ViewRecoveryKey, Configure, BackUpNow]}>
|
||||
<Container size="large" center>
|
||||
{#if repository && schedule}
|
||||
<Stack class="mt-4" gap={8}>
|
||||
<ImmichManageBackupOverview {repository} {schedule} />
|
||||
<BackendsList {repository} />
|
||||
<RepositoryRunHistory {repository} />
|
||||
<RepositorySnapshotsList {repository} />
|
||||
</Stack>
|
||||
{/if}
|
||||
</Container>
|
||||
</PageLayout>
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import StackList from "$lib/components/ui/StackList.svelte";
|
||||
import StackListItem from "$lib/components/ui/StackListItem.svelte";
|
||||
import RelativeTime from "$lib/components/util/RelativeTime.svelte";
|
||||
import type { LocalRepositoryDto, ScheduleDto } from "$lib/fetch-client";
|
||||
import {
|
||||
handlePauseSchedule,
|
||||
handleResumeSchedule,
|
||||
} from "$lib/services/schedule.service";
|
||||
import {
|
||||
FormatBytes,
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import {
|
||||
mdiAlert,
|
||||
mdiArchiveOutline,
|
||||
mdiCheck,
|
||||
mdiInformation,
|
||||
mdiPauseCircleOutline,
|
||||
mdiPlayCircleOutline,
|
||||
} from "@mdi/js";
|
||||
import cronstrue from "cronstrue";
|
||||
|
||||
type Props = {
|
||||
repository: LocalRepositoryDto;
|
||||
schedule: ScheduleDto;
|
||||
};
|
||||
|
||||
const { repository, schedule }: Props = $props();
|
||||
|
||||
function togglePause() {
|
||||
if (schedule!.paused) {
|
||||
handleResumeSchedule(schedule!.id, "Immich");
|
||||
} else {
|
||||
handlePauseSchedule(schedule!.id, "Immich");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<StackList>
|
||||
<StackListItem>
|
||||
{#snippet icon()}
|
||||
<Icon icon={mdiArchiveOutline} size="32px" />
|
||||
{/snippet}
|
||||
|
||||
<HStack gap={4}>
|
||||
<Stack gap={0}>
|
||||
<Heading size="tiny">Your library</Heading>
|
||||
<Text>
|
||||
<FormatBytes bytes={repository.metrics.sizeBytes} /> ·
|
||||
<span class="lowercase"
|
||||
>{cronstrue.toString(schedule.cron, {
|
||||
verbose: true,
|
||||
})}</span
|
||||
>
|
||||
</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
|
||||
{#snippet trailing()}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onclick={togglePause}
|
||||
aria-label={schedule.paused ? "Resume backups" : "Pause backups"}
|
||||
icon={schedule.paused ? mdiPlayCircleOutline : mdiPauseCircleOutline}
|
||||
/>
|
||||
{/snippet}
|
||||
</StackListItem>
|
||||
|
||||
{#if repository.metrics.lastBackup}
|
||||
{#if repository.metrics.lastBackup !== repository.metrics.lastSuccessfulBackup}
|
||||
<StackListItem class="bg-danger-100">
|
||||
<HStack>
|
||||
<Icon icon={mdiAlert} /> Last backup failed <RelativeTime
|
||||
time={repository.metrics.lastBackup}
|
||||
/>
|
||||
</HStack>
|
||||
</StackListItem>
|
||||
{:else}
|
||||
<StackListItem class="bg-success-50">
|
||||
<HStack>
|
||||
<Icon icon={mdiCheck} /> Last backup successful <RelativeTime
|
||||
time={repository.metrics.lastBackup}
|
||||
/>
|
||||
</HStack>
|
||||
</StackListItem>
|
||||
{/if}
|
||||
{:else}
|
||||
<StackListItem class="bg-warning-50">
|
||||
<HStack>
|
||||
<Icon icon={mdiInformation} /> Backup is yet to run.
|
||||
</HStack>
|
||||
</StackListItem>
|
||||
{/if}
|
||||
</StackList>
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import OnboardingStageBackupServices from "$lib/components/onboarding/stages/OnboardingStageBackupServices.svelte";
|
||||
import OnboardingStageKeyImport from "$lib/components/onboarding/stages/OnboardingStageKeyImport.svelte";
|
||||
import RestorePointFlow from "$lib/components/onboarding/restore-point-flow/RestorePointFlow.svelte";
|
||||
import type { OnboardingStatusResponseDto } from "$lib/fetch-client";
|
||||
import {
|
||||
handleConfirmRecoveryKey,
|
||||
handleOnboardingStatus,
|
||||
} from "$lib/services/onboarding.service";
|
||||
import { LoadingSpinner } from "@immich/ui";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
type Props = {
|
||||
onExit: () => void;
|
||||
onFinish: () => void;
|
||||
};
|
||||
|
||||
const { onExit, onFinish }: Props = $props();
|
||||
|
||||
let status: OnboardingStatusResponseDto | undefined = $state();
|
||||
let stage:
|
||||
| "key-import"
|
||||
| "backup-service"
|
||||
| "restore-point"
|
||||
| "key-reimport" = $state("key-import");
|
||||
|
||||
onMount(() => {
|
||||
handleOnboardingStatus().then((data) => {
|
||||
status = data;
|
||||
|
||||
if (data.hasOnboardedKey) {
|
||||
if (data.hasBackend) {
|
||||
stage = "restore-point";
|
||||
} else {
|
||||
stage = "backup-service";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const onKeyImported = async () => {
|
||||
await handleConfirmRecoveryKey();
|
||||
|
||||
if (status?.hasBackend) {
|
||||
stage = "restore-point";
|
||||
} else {
|
||||
stage = "backup-service";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if typeof status === "object"}
|
||||
{#if stage === "key-import"}
|
||||
<OnboardingStageKeyImport onImported={onKeyImported} onCancel={onExit} />
|
||||
{:else if stage === "backup-service"}
|
||||
<OnboardingStageBackupServices
|
||||
onNext={() => (stage = "restore-point")}
|
||||
onCancel={onExit}
|
||||
restore
|
||||
/>
|
||||
{:else if stage === "key-reimport"}
|
||||
<OnboardingStageKeyImport
|
||||
onImported={() => (stage = "restore-point")}
|
||||
onCancel={() => (stage = "restore-point")}
|
||||
/>
|
||||
{:else if stage === "restore-point"}
|
||||
<RestorePointFlow
|
||||
onImportKey={() => (stage = "key-reimport")}
|
||||
onCancel={onExit}
|
||||
{onFinish}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import OnboardingStageBackupServices from "$lib/components/onboarding/stages/OnboardingStageBackupServices.svelte";
|
||||
import OnboardingStageKeyConfirm from "$lib/components/onboarding/stages/OnboardingStageKeyConfirm.svelte";
|
||||
import OnboardingStageKeyIntro from "$lib/components/onboarding/stages/OnboardingStageKeyIntro.svelte";
|
||||
import OnboardingStageSaveKey from "$lib/components/onboarding/stages/OnboardingStageKeySave.svelte";
|
||||
import OnboardingStageWelcome from "$lib/components/onboarding/stages/OnboardingStageWelcome.svelte";
|
||||
import type { OnboardingStatusResponseDto } from "$lib/fetch-client";
|
||||
import {
|
||||
handleConfirmRecoveryKey,
|
||||
handleCurrentRecoveryKey,
|
||||
handleOnboardingStatus,
|
||||
} from "$lib/services/onboarding.service";
|
||||
import { LoadingSpinner } from "@immich/ui";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import ImmichConfigureBackup from "./ImmichConfigureBackup.svelte";
|
||||
import ImmichConfirmDefaultBackup from "./ImmichConfirmDefaultBackup.svelte";
|
||||
|
||||
type Props = {
|
||||
onExit: () => void;
|
||||
children: Snippet;
|
||||
};
|
||||
|
||||
const { onExit, children }: Props = $props();
|
||||
|
||||
let code = $state("");
|
||||
let backendId = $state("");
|
||||
let status: OnboardingStatusResponseDto | undefined = $state();
|
||||
let stage:
|
||||
| `welcome`
|
||||
| `key-${"intro" | "save" | "confirm" | "import"}`
|
||||
| `backup-${"service" | "confirm" | "create"}`
|
||||
| `finished` = $state("welcome");
|
||||
|
||||
onMount(() => {
|
||||
handleOnboardingStatus().then(async (data) => {
|
||||
status = data;
|
||||
|
||||
if (data.hasOnboardedKey) {
|
||||
if (data.hasBackup) {
|
||||
stage = "finished";
|
||||
} else {
|
||||
stage = "backup-service";
|
||||
}
|
||||
} else {
|
||||
const { recoveryKey } = await handleCurrentRecoveryKey();
|
||||
code = recoveryKey;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const onConfirmKey = async () => {
|
||||
await handleConfirmRecoveryKey();
|
||||
stage = "backup-service";
|
||||
};
|
||||
|
||||
const onSelectBackend = (id: string) => {
|
||||
backendId = id;
|
||||
stage = "backup-confirm";
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if typeof status === "object"}
|
||||
{#if stage === "finished"}
|
||||
{@render children()}
|
||||
{:else if stage === "welcome"}
|
||||
<OnboardingStageWelcome
|
||||
onNext={() => (stage = "key-intro")}
|
||||
onImportKey={() => (stage = "key-import")}
|
||||
onCancel={onExit}
|
||||
/>
|
||||
{:else if stage === "key-intro"}
|
||||
<OnboardingStageKeyIntro
|
||||
onNext={() => (stage = "key-save")}
|
||||
onCancel={onExit}
|
||||
/>
|
||||
{:else if stage === "key-save"}
|
||||
<OnboardingStageSaveKey
|
||||
{code}
|
||||
onNext={() => (stage = "key-confirm")}
|
||||
onCancel={onExit}
|
||||
/>
|
||||
{:else if stage === "key-confirm"}
|
||||
<OnboardingStageKeyConfirm
|
||||
{code}
|
||||
onBack={() => (stage = "key-save")}
|
||||
onCancel={onExit}
|
||||
{onConfirmKey}
|
||||
/>
|
||||
{:else if stage === "backup-service"}
|
||||
<OnboardingStageBackupServices onNext={onSelectBackend} onCancel={onExit} />
|
||||
{:else if stage === "backup-confirm"}
|
||||
<ImmichConfirmDefaultBackup
|
||||
onCustomize={() => (stage = "backup-create")}
|
||||
onConfirm={() => (stage = "finished")}
|
||||
onCancel={onExit}
|
||||
/>
|
||||
{:else if stage === "backup-create"}
|
||||
<ImmichConfigureBackup
|
||||
onFinish={() => (stage = "finished")}
|
||||
onCancel={onExit}
|
||||
{backendId}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<LoadingSpinner />
|
||||
{/if}
|
||||
@@ -1,141 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { OnboardingStatusResponseDto } from "$lib/fetch-client";
|
||||
import {
|
||||
handleConfirmRecoveryKey,
|
||||
handleCurrentRecoveryKey,
|
||||
handleSkipOnboardingExtraConfig,
|
||||
} from "$lib/services/onboarding.service";
|
||||
import { onMount } from "svelte";
|
||||
import BackupOptions from "./stages/BackupOptions.svelte";
|
||||
import ConfirmKey from "./stages/ConfirmKey.svelte";
|
||||
import KeyIntro from "./stages/KeyIntro.svelte";
|
||||
import SaveKey from "./stages/SaveKey.svelte";
|
||||
import Welcome from "./stages/Welcome.svelte";
|
||||
import ImportKey from "./stages/ImportKey.svelte";
|
||||
import CreateFirstBackup from "./stages/CreateFirstBackup.svelte";
|
||||
import CreateFirstSchedule from "./stages/CreateFirstSchedule.svelte";
|
||||
import CreateImmichBackup from "../backups/dialogs/ConfigureImmichBackup.svelte";
|
||||
import RestorePointFlow from "./restore-point-flow/RestorePointFlow.svelte";
|
||||
|
||||
type Props = {
|
||||
flow?: "default" | "immich-setup" | "immich-restore";
|
||||
status: OnboardingStatusResponseDto;
|
||||
onFinish: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
let code = $state("");
|
||||
|
||||
const { flow = "default", status, onFinish, onCancel }: Props = $props();
|
||||
const onNext = () => stage++;
|
||||
const onBack = () => stage--;
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let stage = $state(
|
||||
// immich-restore
|
||||
flow === "immich-restore"
|
||||
? status.hasOnboardedKey
|
||||
? status.hasBackend
|
||||
? 2
|
||||
: 1
|
||||
: 0
|
||||
: // immich-setup & default
|
||||
status.hasOnboardedKey
|
||||
? status.hasBackend
|
||||
? status.hasBackup
|
||||
? 7
|
||||
: 6
|
||||
: 4
|
||||
: 0,
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
if (!status.hasOnboardedKey) {
|
||||
handleCurrentRecoveryKey().then((dto) => (code = dto.recoveryKey));
|
||||
}
|
||||
});
|
||||
|
||||
const onSkip = () => {
|
||||
void handleSkipOnboardingExtraConfig();
|
||||
onFinish();
|
||||
};
|
||||
|
||||
const onConfirmKey = async () => {
|
||||
await handleConfirmRecoveryKey();
|
||||
|
||||
if (status.hasBackend) {
|
||||
onFinish();
|
||||
} else {
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if flow === "immich-restore"}
|
||||
<!-- Immich Restore -->
|
||||
{#if stage === 0}
|
||||
<ImportKey
|
||||
onImported={(key) => {
|
||||
code = key;
|
||||
onConfirmKey();
|
||||
}}
|
||||
{onCancel}
|
||||
/>
|
||||
{:else if stage === 1}
|
||||
<BackupOptions onNext={() => (stage = 2)} {onCancel} restore />
|
||||
{:else if stage === 10}
|
||||
<ImportKey onImported={() => (stage = 2)} onCancel={() => (stage = 2)} />
|
||||
{:else}
|
||||
<RestorePointFlow onImportKey={() => (stage = 10)} {onCancel} {onFinish} />
|
||||
{/if}
|
||||
{:else if flow === "immich-setup"}
|
||||
<!-- Immich Setup -->
|
||||
{#if stage === 0}
|
||||
<Welcome {onNext} onImportKey={() => (stage = 5)} {onCancel} />
|
||||
{:else if stage === 1}
|
||||
<KeyIntro {onNext} {onCancel} />
|
||||
{:else if stage === 2}
|
||||
<SaveKey {code} {onNext} {onCancel} />
|
||||
{:else if stage === 3}
|
||||
<ConfirmKey {code} {onConfirmKey} {onBack} {onCancel} />
|
||||
{:else if stage === 4}
|
||||
<BackupOptions onNext={() => (stage = 6)} {onCancel} />
|
||||
{:else if stage === 5}
|
||||
<ImportKey
|
||||
onStart={() => (stage = 0)}
|
||||
onImported={(key) => {
|
||||
code = key;
|
||||
stage = 2;
|
||||
}}
|
||||
{onCancel}
|
||||
/>
|
||||
{:else}
|
||||
<CreateImmichBackup onClose={onSkip} initialConfiguration />
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Sample Flow -->
|
||||
{#if stage === 0}
|
||||
<Welcome {onNext} onImportKey={() => (stage = 5)} {onCancel} />
|
||||
{:else if stage === 1}
|
||||
<KeyIntro {onNext} {onCancel} />
|
||||
{:else if stage === 2}
|
||||
<SaveKey {code} {onNext} {onCancel} />
|
||||
{:else if stage === 3}
|
||||
<ConfirmKey {code} {onConfirmKey} {onBack} {onCancel} />
|
||||
{:else if stage === 4}
|
||||
<BackupOptions onNext={() => (stage = 6)} {onCancel} />
|
||||
{:else if stage === 5}
|
||||
<ImportKey
|
||||
onStart={() => (stage = 0)}
|
||||
onImported={(key) => {
|
||||
code = key;
|
||||
stage = 2;
|
||||
}}
|
||||
{onCancel}
|
||||
/>
|
||||
{:else if stage === 6}
|
||||
<CreateFirstBackup {onNext} {onSkip} />
|
||||
{:else if stage === 7}
|
||||
<CreateFirstSchedule {onFinish} {onSkip} />
|
||||
{/if}
|
||||
{/if}
|
||||
+3
-5
@@ -1,18 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { LoadingSpinner } from "@immich/ui";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import Onboarding from "./Onboarding.svelte";
|
||||
import SampleOnboarding from "./SampleOnboarding.svelte";
|
||||
import type { OnboardingStatusResponseDto } from "$lib/fetch-client";
|
||||
import { handleOnboardingStatus } from "$lib/services/onboarding.service";
|
||||
|
||||
type Props = {
|
||||
flow?: "default" | "immich-setup" | "immich-restore";
|
||||
onExit: () => void;
|
||||
onFinish?: () => void;
|
||||
children: Snippet;
|
||||
};
|
||||
|
||||
const { flow, onExit, onFinish, children }: Props = $props();
|
||||
const { onExit, onFinish, children }: Props = $props();
|
||||
|
||||
let status: OnboardingStatusResponseDto | undefined = $state();
|
||||
|
||||
@@ -33,8 +32,7 @@
|
||||
|
||||
{#if typeof status === "object"}
|
||||
{#if !(status.hasBackend && status.hasOnboardedKey && (status.hasSkippedExtraConfig || (status.hasBackup && status.hasSchedule)))}
|
||||
<Onboarding
|
||||
{flow}
|
||||
<SampleOnboarding
|
||||
{status}
|
||||
onFinish={() => (onFinish ? onFinish() : onSkip())}
|
||||
onCancel={() => {
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import type { OnboardingStatusResponseDto } from "$lib/fetch-client";
|
||||
import {
|
||||
handleConfirmRecoveryKey,
|
||||
handleCurrentRecoveryKey,
|
||||
handleSkipOnboardingExtraConfig,
|
||||
} from "$lib/services/onboarding.service";
|
||||
import { onMount } from "svelte";
|
||||
import CreateFirstBackup from "./stages/SampleCreateFirstBackup.svelte";
|
||||
import CreateFirstSchedule from "./stages/SampleCreateFirstSchedule.svelte";
|
||||
import BackupOptions from "./stages/OnboardingStageBackupServices.svelte";
|
||||
import ConfirmKey from "./stages/OnboardingStageKeyConfirm.svelte";
|
||||
import ImportKey from "./stages/OnboardingStageKeyImport.svelte";
|
||||
import KeyIntro from "./stages/OnboardingStageKeyIntro.svelte";
|
||||
import SaveKey from "./stages/OnboardingStageKeySave.svelte";
|
||||
import Welcome from "./stages/OnboardingStageWelcome.svelte";
|
||||
|
||||
type Props = {
|
||||
status: OnboardingStatusResponseDto;
|
||||
onFinish: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
let code = $state("");
|
||||
|
||||
const { status, onFinish, onCancel }: Props = $props();
|
||||
const onNext = () => stage++;
|
||||
const onBack = () => stage--;
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
let stage = $state(
|
||||
status.hasOnboardedKey
|
||||
? status.hasBackend
|
||||
? status.hasBackup
|
||||
? 7
|
||||
: 6
|
||||
: 4
|
||||
: 0,
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
if (!status.hasOnboardedKey) {
|
||||
handleCurrentRecoveryKey().then((dto) => (code = dto.recoveryKey));
|
||||
}
|
||||
});
|
||||
|
||||
const onSkip = () => {
|
||||
void handleSkipOnboardingExtraConfig();
|
||||
onFinish();
|
||||
};
|
||||
|
||||
const onConfirmKey = async () => {
|
||||
await handleConfirmRecoveryKey();
|
||||
|
||||
if (status.hasBackend) {
|
||||
onFinish();
|
||||
} else {
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if stage === 0}
|
||||
<Welcome {onNext} onImportKey={() => (stage = 5)} {onCancel} />
|
||||
{:else if stage === 1}
|
||||
<KeyIntro {onNext} {onCancel} />
|
||||
{:else if stage === 2}
|
||||
<SaveKey {code} {onNext} {onCancel} />
|
||||
{:else if stage === 3}
|
||||
<ConfirmKey {code} {onConfirmKey} {onBack} {onCancel} />
|
||||
{:else if stage === 4}
|
||||
<BackupOptions onNext={() => (stage = 6)} {onCancel} />
|
||||
{:else if stage === 5}
|
||||
<ImportKey
|
||||
onStart={() => (stage = 0)}
|
||||
onImported={(key) => {
|
||||
code = key;
|
||||
stage = 2;
|
||||
}}
|
||||
{onCancel}
|
||||
/>
|
||||
{:else if stage === 6}
|
||||
<CreateFirstBackup {onNext} {onSkip} />
|
||||
{:else if stage === 7}
|
||||
<CreateFirstSchedule {onFinish} {onSkip} />
|
||||
{/if}
|
||||
+31
-42
@@ -1,20 +1,14 @@
|
||||
<script lang="ts">
|
||||
import StackList from "$lib/components/ui/StackList.svelte";
|
||||
import { useInspectRepositories } from "$lib/services/repository.service";
|
||||
import { getReadableErrorMessage } from "$lib/utils/handle-error";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
Heading,
|
||||
HStack,
|
||||
LoadingSpinner,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Stack,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@immich/ui";
|
||||
import RestorePointFlow2SelectSnapshot from "./RestorePointFlow2SelectSnapshot.svelte";
|
||||
|
||||
@@ -56,44 +50,39 @@
|
||||
{:else}
|
||||
<Modal title="Select Restore Point" size="small" onClose={onCancel}>
|
||||
<ModalBody>
|
||||
<VStack>
|
||||
{#if query.isPending}
|
||||
<LoadingSpinner />
|
||||
{:else if query.isError}
|
||||
<Alert color="danger">{getReadableErrorMessage(query.error)}</Alert>
|
||||
{:else if query.isSuccess && sortedRepositories?.length}
|
||||
{#each sortedRepositories as repo (repo.id)}
|
||||
<StackList {query}>
|
||||
{#snippet children()}
|
||||
{#each sortedRepositories ?? [] as repo (repo.id)}
|
||||
{@const accessible = repo.snapshots !== undefined}
|
||||
<Card class={accessible ? "" : "opacity-50"}>
|
||||
<CardBody>
|
||||
<HStack>
|
||||
<Stack gap={0} class="flex-1">
|
||||
<Heading size="small">{repo.name}</Heading>
|
||||
<Text color={!accessible ? "danger" : undefined}>
|
||||
{#if !accessible}
|
||||
Unable to access repository
|
||||
{:else if repo.snapshots.length}
|
||||
Last backup: {new Date(
|
||||
repo.snapshots[0].time,
|
||||
).toLocaleDateString()}
|
||||
{:else}
|
||||
No backups yet
|
||||
{/if}
|
||||
</Text>
|
||||
</Stack>
|
||||
{#if accessible}
|
||||
<Button onclick={() => (selectedRepository = repo.id)}
|
||||
>Select</Button
|
||||
>
|
||||
<HStack gap={2} class="px-4 py-3">
|
||||
<Stack gap={0} class="grow min-w-0">
|
||||
<Text>{repo.name}</Text>
|
||||
<Text size="small" color={accessible ? "secondary" : "danger"}>
|
||||
{#if !accessible}
|
||||
Unable to access repository
|
||||
{:else if repo.snapshots.length}
|
||||
Last backup: {new Date(
|
||||
repo.snapshots[0].time,
|
||||
).toLocaleDateString()}
|
||||
{:else}
|
||||
No backups yet
|
||||
{/if}
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Text>
|
||||
</Stack>
|
||||
{#if accessible}
|
||||
<Button onclick={() => (selectedRepository = repo.id)}>
|
||||
Select
|
||||
</Button>
|
||||
{/if}
|
||||
</HStack>
|
||||
{/each}
|
||||
{:else}
|
||||
<Text>No repositories found.</Text>
|
||||
{/if}
|
||||
</VStack>
|
||||
{#if (sortedRepositories ?? []).length === 0}
|
||||
<Text class="text-center py-6" color="muted">
|
||||
No repositories found.
|
||||
</Text>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</StackList>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
|
||||
+19
-20
@@ -1,19 +1,17 @@
|
||||
<script lang="ts">
|
||||
import StackList from "$lib/components/ui/StackList.svelte";
|
||||
import type {
|
||||
InspectedLocalRepositoryDto,
|
||||
SnapshotDto,
|
||||
} from "$lib/fetch-client";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
HStack,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Stack,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@immich/ui";
|
||||
import RestorePointFlow3ConfirmRestore from "./RestorePointFlow3ConfirmRestore.svelte";
|
||||
|
||||
@@ -43,25 +41,26 @@
|
||||
onClose={onBack}
|
||||
>
|
||||
<ModalBody>
|
||||
<VStack>
|
||||
<StackList>
|
||||
{#each repository.snapshots.toSorted((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime()) as snapshot (snapshot.id)}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<HStack>
|
||||
<Stack gap={0} class="flex-1">
|
||||
<Text>{new Date(snapshot.time).toLocaleString()}</Text>
|
||||
{#each snapshot.paths as path}
|
||||
<Text color="muted">{path}</Text>
|
||||
{/each}
|
||||
</Stack>
|
||||
<Button onclick={() => (selectedSnapshot = snapshot)}>
|
||||
Restore
|
||||
</Button>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<HStack gap={2} class="px-4 py-3">
|
||||
<Stack gap={0} class="grow min-w-0">
|
||||
<Text>{new Date(snapshot.time).toLocaleString()}</Text>
|
||||
{#each snapshot.paths as path}
|
||||
<Text size="small" color="muted">{path}</Text>
|
||||
{/each}
|
||||
</Stack>
|
||||
<Button onclick={() => (selectedSnapshot = snapshot)}>
|
||||
Restore
|
||||
</Button>
|
||||
</HStack>
|
||||
{/each}
|
||||
</VStack>
|
||||
{#if repository.snapshots.length === 0}
|
||||
<Text class="text-center py-6" color="muted">
|
||||
No snapshots in this repository.
|
||||
</Text>
|
||||
{/if}
|
||||
</StackList>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { handleYuccaLogin } from "$lib/services/backend.service";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
Heading,
|
||||
HStack,
|
||||
modalManager,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@immich/ui";
|
||||
import CreateLocalBackend from "$lib/components/backends/CreateLocalBackend.svelte";
|
||||
import OAuthDeviceFlow from "$lib/components/backends/OAuthDeviceFlow.svelte";
|
||||
|
||||
type Props = {
|
||||
restore?: boolean;
|
||||
onNext: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const { restore = false, onNext, onCancel }: Props = $props();
|
||||
|
||||
async function onFutoBackups() {
|
||||
modalManager.show(OAuthDeviceFlow, {
|
||||
...(await handleYuccaLogin()),
|
||||
onCreate: onNext,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
size="small"
|
||||
title={restore ? "Where would you like to restore from?" : "Backup options"}
|
||||
onClose={onCancel}
|
||||
>
|
||||
<ModalBody>
|
||||
<VStack>
|
||||
<Card class="cursor-pointer" onclick={onFutoBackups}>
|
||||
<CardBody>
|
||||
{#if restore}
|
||||
<Heading size="small">FUTO Backups</Heading>
|
||||
{:else}
|
||||
<Heading size="small">$0/TB per month</Heading>
|
||||
{/if}
|
||||
<Text>Backups powered by FUTO</Text>
|
||||
<Text>Alerts and monitoring included</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<!-- <Card class="cursor-pointer">
|
||||
<CardBody>
|
||||
<Heading size="small">Self-managed</Heading>
|
||||
<Text>Hook up your own S3 provider</Text>
|
||||
</CardBody>
|
||||
</Card> -->
|
||||
<Card
|
||||
class="cursor-pointer"
|
||||
onclick={() =>
|
||||
modalManager.open(CreateLocalBackend, {
|
||||
onCreated: onNext,
|
||||
})}
|
||||
>
|
||||
<CardBody>
|
||||
<Heading size="small">Local Folder</Heading>
|
||||
<Text>Select a local destination</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<Button variant="ghost" onclick={onCancel}>Cancel</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
<script lang="ts">
|
||||
import TimedButton from "$lib/components/util/TimedButton.svelte";
|
||||
import {
|
||||
Button,
|
||||
HStack,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@immich/ui";
|
||||
|
||||
type Props = {
|
||||
onNext: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const { onNext, onCancel }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal size="small" title="Keeping your photos private" onClose={onCancel}>
|
||||
<ModalBody>
|
||||
<VStack>
|
||||
<Text class="text-center"
|
||||
>You are about to be given a code which you need to put in a safe place.
|
||||
It will be used to allow you to access your backups later.</Text
|
||||
>
|
||||
|
||||
<Text class="text-center text-red-500"
|
||||
>If you lose it, you won't be able to access your photo backup!</Text
|
||||
>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<TimedButton text="Next" onclick={onNext} />
|
||||
<Button variant="ghost" onclick={onCancel}>Cancel</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import StackList from "$lib/components/ui/StackList.svelte";
|
||||
import StackListItem from "$lib/components/ui/StackListItem.svelte";
|
||||
import {
|
||||
handleSetupLocalStorage,
|
||||
handleYuccaLogin,
|
||||
} from "$lib/services/backend.service";
|
||||
import {
|
||||
Button,
|
||||
HStack,
|
||||
Icon,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import { mdiChevronRight, mdiHarddisk, mdiShieldCheck } from "@mdi/js";
|
||||
|
||||
type Props = {
|
||||
restore?: boolean;
|
||||
onNext: (backendId: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const { restore = false, onNext, onCancel }: Props = $props();
|
||||
|
||||
function onFutoBackups() {
|
||||
handleYuccaLogin(onNext);
|
||||
}
|
||||
|
||||
function onLocalBackups() {
|
||||
handleSetupLocalStorage(onNext);
|
||||
}
|
||||
|
||||
// TODO: show existing backends if any configured!
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
size="small"
|
||||
title={restore ? "Where would you like to restore from?" : "Backup options"}
|
||||
onClose={onCancel}
|
||||
icon={false}
|
||||
>
|
||||
<ModalBody>
|
||||
<StackList>
|
||||
<StackListItem onclick={onFutoBackups}>
|
||||
{#snippet icon()}
|
||||
<Icon icon={mdiShieldCheck} size="36px" />
|
||||
{/snippet}
|
||||
|
||||
<Stack gap={0}>
|
||||
<Text class="font-bold">FUTO Backups</Text>
|
||||
<Text>Simple, hosted backups.</Text>
|
||||
</Stack>
|
||||
|
||||
{#snippet trailing()}
|
||||
<Icon icon={mdiChevronRight} />
|
||||
{/snippet}
|
||||
</StackListItem>
|
||||
<StackListItem onclick={onLocalBackups}>
|
||||
{#snippet icon()}
|
||||
<Icon icon={mdiHarddisk} size="36px" />
|
||||
{/snippet}
|
||||
|
||||
<Stack gap={0}>
|
||||
<Text class="font-bold">Local Storage</Text>
|
||||
<Text>A folder on this computer.</Text>
|
||||
</Stack>
|
||||
|
||||
{#snippet trailing()}
|
||||
<Icon icon={mdiChevronRight} />
|
||||
{/snippet}
|
||||
</StackListItem>
|
||||
</StackList>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<Button variant="ghost" onclick={onCancel}>Cancel</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
+14
-4
@@ -7,7 +7,8 @@
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
VStack,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
|
||||
type Props = {
|
||||
@@ -27,13 +28,22 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="small" title="Confirm recovery key" onClose={onCancel}>
|
||||
<Modal
|
||||
size="small"
|
||||
title="Confirm recovery key"
|
||||
onClose={onCancel}
|
||||
icon={false}
|
||||
>
|
||||
<ModalBody>
|
||||
<VStack>
|
||||
<Stack>
|
||||
<Text size="small" class="text-muted text-left"
|
||||
>Ensure you've saved the key properly.</Text
|
||||
>
|
||||
|
||||
<Field label="Recovery Key">
|
||||
<Input bind:value />
|
||||
</Field>
|
||||
</VStack>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import TimedButton from "$lib/components/util/TimedButton.svelte";
|
||||
import {
|
||||
Button,
|
||||
HStack,
|
||||
Icon,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import { mdiKey, mdiSecurity } from "@mdi/js";
|
||||
|
||||
type Props = {
|
||||
onNext: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const { onNext, onCancel }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
size="small"
|
||||
title="Keeping your photos private"
|
||||
onClose={onCancel}
|
||||
icon={false}
|
||||
>
|
||||
<ModalBody>
|
||||
<Stack>
|
||||
<HStack>
|
||||
<Icon icon={mdiKey} class="shrink-0 place-self-start mt-1" />
|
||||
<Text
|
||||
>You are about to be given a code which you need to put in a safe
|
||||
place.</Text
|
||||
>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Icon icon={mdiSecurity} class="shrink-0 place-self-start mt-1" />
|
||||
<Text>If you lose the key, your backup is gone.</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
<TimedButton text="Next" onclick={onNext} />
|
||||
<Button variant="ghost" onclick={onCancel}>Cancel</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
+33
-12
@@ -8,8 +8,11 @@
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Stack,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@immich/ui";
|
||||
import { mdiAsterisk, mdiContentCopy, mdiFile, mdiPrinter } from "@mdi/js";
|
||||
|
||||
type Props = {
|
||||
code: string;
|
||||
@@ -75,10 +78,14 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="small" title="Your recovery key" onClose={onCancel}>
|
||||
<Modal size="small" title="Your recovery key" onClose={onCancel} icon={false}>
|
||||
<ModalBody>
|
||||
<VStack>
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Text size="small" class="text-muted text-left"
|
||||
>Save this key somewhere safe, it will be used to restore your backups.</Text
|
||||
>
|
||||
|
||||
<Card class="bg-primary-50 shadow-none">
|
||||
<CardBody class="flex justify-center">
|
||||
{#if code}
|
||||
<pre><code>{code}</code></pre>
|
||||
@@ -88,15 +95,29 @@
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<HStack>
|
||||
<Button onclick={print}>Print</Button>
|
||||
<Button onclick={saveFile}>Save as file</Button>
|
||||
</HStack>
|
||||
<Button onclick={copyToClipboard}>Copy to clipboard</Button>
|
||||
{#if PasswordCredential}
|
||||
<Button onclick={storeCredentials}>Save to password manager</Button>
|
||||
{/if}
|
||||
</VStack>
|
||||
<VStack>
|
||||
<HStack>
|
||||
<Button leadingIcon={mdiPrinter} variant="outline" onclick={print}
|
||||
>Print</Button
|
||||
>
|
||||
<Button leadingIcon={mdiFile} variant="outline" onclick={saveFile}
|
||||
>Save as file</Button
|
||||
>
|
||||
</HStack>
|
||||
<Button
|
||||
leadingIcon={mdiContentCopy}
|
||||
variant="outline"
|
||||
onclick={copyToClipboard}>Copy to clipboard</Button
|
||||
>
|
||||
{#if PasswordCredential}
|
||||
<Button
|
||||
leadingIcon={mdiAsterisk}
|
||||
variant="outline"
|
||||
onclick={storeCredentials}>Save to password manager</Button
|
||||
>
|
||||
{/if}
|
||||
</VStack>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
+6
-5
@@ -6,8 +6,8 @@
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Stack,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@immich/ui";
|
||||
import { mdiImageAlbum, mdiInformation, mdiLock } from "@mdi/js";
|
||||
|
||||
@@ -15,14 +15,15 @@
|
||||
onNext: () => void;
|
||||
onImportKey: () => void;
|
||||
onCancel: () => void;
|
||||
icon?: boolean;
|
||||
};
|
||||
|
||||
const { onNext, onImportKey, onCancel }: Props = $props();
|
||||
const { onNext, onImportKey, onCancel, icon = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal size="small" title="Welcome to backups" onClose={onCancel}>
|
||||
<Modal size="small" title="Welcome to FUTO Backups" onClose={onCancel} {icon}>
|
||||
<ModalBody>
|
||||
<VStack>
|
||||
<Stack>
|
||||
<HStack>
|
||||
<Icon icon={mdiImageAlbum} class="shrink-0 place-self-start mt-1" />
|
||||
<Text>
|
||||
@@ -43,7 +44,7 @@
|
||||
storage.</Text
|
||||
>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { orchestrationApiProvider, setProvider } from "$lib/providers";
|
||||
import {
|
||||
AppShell,
|
||||
AppShellHeader,
|
||||
AppShellSidebar,
|
||||
Avatar,
|
||||
Button,
|
||||
IconButton,
|
||||
Input,
|
||||
Logo,
|
||||
NavbarGroup,
|
||||
NavbarItem,
|
||||
ThemeSwitcher,
|
||||
} from "@immich/ui";
|
||||
import {
|
||||
mdiAccountMultipleOutline,
|
||||
mdiArchiveArrowDownOutline,
|
||||
mdiBackupRestore,
|
||||
mdiBellOutline,
|
||||
mdiCastVariant,
|
||||
mdiHeartOutline,
|
||||
mdiImageAlbum,
|
||||
mdiImageMultipleOutline,
|
||||
mdiLockOutline,
|
||||
mdiMagnify,
|
||||
mdiMapOutline,
|
||||
mdiMenu,
|
||||
mdiToolboxOutline,
|
||||
mdiTrashCanOutline,
|
||||
mdiTrayArrowUp,
|
||||
mdiTune,
|
||||
} from "@mdi/js";
|
||||
import { options } from "$lib/options";
|
||||
import { onDestroy } from "svelte";
|
||||
import ImmichManageBackup from "../integrations/immich/ImmichManageBackup.svelte";
|
||||
import ImmichOnboardingRestoreFlow from "../integrations/immich/ImmichOnboardingRestoreFlow.svelte";
|
||||
import ImmichOnboardingSetupFlow from "../integrations/immich/ImmichOnboardingSetupFlow.svelte";
|
||||
|
||||
type Props = {
|
||||
onExit: () => void;
|
||||
};
|
||||
|
||||
const { onExit }: Props = $props();
|
||||
const { testUiRestore, demoPadding } = options;
|
||||
|
||||
demoPadding.set(true);
|
||||
onDestroy(() => demoPadding.set(false));
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
setProvider(orchestrationApiProvider);
|
||||
</script>
|
||||
|
||||
<AppShell>
|
||||
<AppShellHeader>
|
||||
<div class="grid grid-cols-[--spacing(64)_auto] items-center w-full py-2">
|
||||
<div class="flex flex-row gap-1 mx-4 items-center">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
aria-label="Main menu"
|
||||
icon={mdiMenu}
|
||||
onclick={() => {}}
|
||||
class="sidebar:hidden"
|
||||
/>
|
||||
<Logo variant="inline" class="h-12" />
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
|
||||
<div class="hidden w-full max-w-5xl flex-1 sm:block">
|
||||
<Input
|
||||
placeholder="Search your photos"
|
||||
leadingIcon={mdiMagnify}
|
||||
trailingIcon={mdiTune}
|
||||
shape="round"
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="flex place-items-center justify-end gap-1 md:gap-2 w-full sm:w-auto"
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
icon={mdiMagnify}
|
||||
href="#"
|
||||
aria-label="Search"
|
||||
class="sm:hidden"
|
||||
/>
|
||||
|
||||
<Button
|
||||
leadingIcon={mdiTrayArrowUp}
|
||||
onclick={() => {}}
|
||||
class="hidden lg:flex"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
color="secondary">Upload</Button
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
onclick={() => {}}
|
||||
title="Upload"
|
||||
aria-label="Upload"
|
||||
icon={mdiTrayArrowUp}
|
||||
class="lg:hidden"
|
||||
/>
|
||||
|
||||
<ThemeSwitcher size="medium" color="secondary" />
|
||||
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
icon={mdiBellOutline}
|
||||
onclick={() => {}}
|
||||
aria-label="Notifications"
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
icon={mdiCastVariant}
|
||||
onclick={() => {}}
|
||||
aria-label="Cast"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex ps-2"
|
||||
onclick={() => {}}
|
||||
title="User"
|
||||
>
|
||||
<Avatar name="FUTO Backups" size="medium" color="primary" />
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</AppShellHeader>
|
||||
|
||||
<AppShellSidebar>
|
||||
<div class="pt-4 pr-2">
|
||||
<NavbarItem href="#" title="Photos" icon={mdiImageMultipleOutline} />
|
||||
<NavbarItem href="#" title="Explore" icon={mdiMagnify} />
|
||||
<NavbarItem href="#" title="Map" icon={mdiMapOutline} />
|
||||
<NavbarItem href="#" title="Sharing" icon={mdiAccountMultipleOutline} />
|
||||
|
||||
<NavbarGroup title="Library" size="tiny" />
|
||||
|
||||
<NavbarItem href="#" title="Favorites" icon={mdiHeartOutline} />
|
||||
<NavbarItem
|
||||
href="#"
|
||||
title="Albums"
|
||||
icon={{ icon: mdiImageAlbum, flipped: true }}
|
||||
/>
|
||||
<NavbarItem href="#" title="Backups" icon={mdiBackupRestore} active />
|
||||
<NavbarItem href="#" title="Utilities" icon={mdiToolboxOutline} />
|
||||
<NavbarItem href="#" title="Archive" icon={mdiArchiveArrowDownOutline} />
|
||||
<NavbarItem href="#" title="Locked folder" icon={mdiLockOutline} />
|
||||
<NavbarItem href="#" title="Trash" icon={mdiTrashCanOutline} />
|
||||
</div>
|
||||
</AppShellSidebar>
|
||||
|
||||
{#if $testUiRestore}
|
||||
<ImmichOnboardingRestoreFlow
|
||||
{onExit}
|
||||
onFinish={() => testUiRestore.set(false)}
|
||||
/>
|
||||
{:else}
|
||||
<ImmichOnboardingSetupFlow {onExit}>
|
||||
<ImmichManageBackup />
|
||||
</ImmichOnboardingSetupFlow>
|
||||
{/if}
|
||||
</AppShell>
|
||||
@@ -10,17 +10,19 @@
|
||||
AppShellSidebar,
|
||||
Heading,
|
||||
NavbarItem,
|
||||
Text,
|
||||
ThemeSwitcher,
|
||||
} from "@immich/ui";
|
||||
import BackendsList from "../backends/BackendsList.svelte";
|
||||
import BackupsList from "../backups/BackupsList.svelte";
|
||||
import {
|
||||
mdiBackupRestore,
|
||||
mdiClock,
|
||||
mdiCog,
|
||||
mdiViewDashboard,
|
||||
} from "@mdi/js";
|
||||
import ScheduleList from "../schedules/ScheduleList.svelte";
|
||||
import BackendsList from "../backends/BackendsList.svelte";
|
||||
import BackupsList from "../backups/BackupsList.svelte";
|
||||
import Dashboard from "../dashboard/Dashboard.svelte";
|
||||
import ScheduleList from "../schedules/ScheduleList.svelte";
|
||||
|
||||
const { mock }: { mock: boolean } = $props();
|
||||
|
||||
@@ -37,13 +39,20 @@
|
||||
|
||||
<AppShell>
|
||||
<AppShellHeader>
|
||||
<Heading
|
||||
><img
|
||||
alt="App Name Here"
|
||||
src="/app-name-here.png"
|
||||
class="inline h-12"
|
||||
/></Heading
|
||||
>
|
||||
<div class="flex items-center justify-between w-full px-4 py-2">
|
||||
<Heading>
|
||||
{#if mock}
|
||||
<Text class="h-12 font-bold">FUTO Backups</Text>
|
||||
{:else}
|
||||
<img
|
||||
alt="App Name Here"
|
||||
src="/app-name-here.png"
|
||||
class="inline h-12"
|
||||
/>
|
||||
{/if}
|
||||
</Heading>
|
||||
<ThemeSwitcher size="medium" color="secondary" />
|
||||
</div>
|
||||
</AppShellHeader>
|
||||
|
||||
<AppShellSidebar bind:open>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { options } from "$lib/options";
|
||||
import {
|
||||
Button,
|
||||
ContextMenuButton,
|
||||
HStack,
|
||||
type ActionItem,
|
||||
} from "@immich/ui";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
actions?: ActionItem[];
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
const { title, actions = [], children }: Props = $props();
|
||||
const { demoPadding } = options;
|
||||
</script>
|
||||
|
||||
<main class="relative h-full">
|
||||
{#if title || actions.length > 0}
|
||||
<div
|
||||
class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if title}
|
||||
<div class="outline-none pe-8">{title}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if actions.length > 0}
|
||||
<div class="hidden md:block">
|
||||
<HStack gap={0}>
|
||||
{#each actions as action, index (index)}
|
||||
{#if !action.$if || action.$if?.()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
color={action.color ?? "secondary"}
|
||||
leadingIcon={action.icon}
|
||||
onclick={() => action.onAction(action)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<ContextMenuButton
|
||||
aria-label="Open"
|
||||
items={actions}
|
||||
class="md:hidden"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="absolute w-full overflow-y-auto {title || actions.length > 0
|
||||
? 'top-16 h-[calc(100%-4rem)]'
|
||||
: 'top-0 h-full'} {$demoPadding ? 'p-4' : ''}"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import StackList from "$lib/components/ui/StackList.svelte";
|
||||
import type { FilesystemListingResponseDto } from "$lib/fetch-client";
|
||||
import {
|
||||
Button,
|
||||
HStack,
|
||||
IconButton,
|
||||
modalManager,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { SvelteSet } from "svelte/reactivity";
|
||||
import PathPickerModal from "./PathPickerModal.svelte";
|
||||
|
||||
type Props = {
|
||||
paths: SvelteSet<string>;
|
||||
label?: Snippet;
|
||||
empty?: Snippet;
|
||||
addLabel?: string;
|
||||
manageLabel?: string;
|
||||
pickerTitle?: string;
|
||||
pickerDescription?: string;
|
||||
foldersOnly?: boolean;
|
||||
handleGetListing?: (path?: string) => Promise<FilesystemListingResponseDto>;
|
||||
};
|
||||
|
||||
let {
|
||||
paths,
|
||||
label,
|
||||
empty,
|
||||
addLabel = "Manage paths",
|
||||
manageLabel = "Manage paths",
|
||||
pickerTitle = "Choose paths",
|
||||
pickerDescription,
|
||||
foldersOnly = false,
|
||||
handleGetListing,
|
||||
}: Props = $props();
|
||||
|
||||
const openPicker = () =>
|
||||
modalManager.show(PathPickerModal, {
|
||||
title: pickerTitle,
|
||||
description: pickerDescription,
|
||||
foldersOnly,
|
||||
initial: [...paths],
|
||||
handleGetListing,
|
||||
onSubmit: (next) => {
|
||||
paths.clear();
|
||||
for (const path of next) paths.add(path);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<StackList>
|
||||
{#snippet title()}
|
||||
{@render label?.()}
|
||||
{/snippet}
|
||||
|
||||
{#if paths.size > 0}
|
||||
{#each [...paths] as path (path)}
|
||||
<HStack gap={2} class="px-4 py-3">
|
||||
<Text class="grow truncate" title={path}>{path}</Text>
|
||||
<IconButton
|
||||
icon={mdiClose}
|
||||
aria-label="Remove"
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
onclick={() => paths.delete(path)}
|
||||
/>
|
||||
</HStack>
|
||||
{/each}
|
||||
{:else if empty}
|
||||
<HStack class="px-4 py-3">
|
||||
<Text color="secondary">{@render empty()}</Text>
|
||||
</HStack>
|
||||
{/if}
|
||||
|
||||
<HStack class="px-4 py-2">
|
||||
<Button size="small" variant="ghost" onclick={openPicker}>
|
||||
{paths.size > 0 ? addLabel : manageLabel}
|
||||
</Button>
|
||||
</HStack>
|
||||
</StackList>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import PathPickerModal from "./PathPickerModal.svelte";
|
||||
import type { FilesystemListingResponseDto } from "$lib/fetch-client";
|
||||
import {
|
||||
Heading,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
modalManager,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import { mdiFolder } from "@mdi/js";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
title?: Snippet;
|
||||
description?: Snippet;
|
||||
placeholder?: string;
|
||||
pickerTitle?: string;
|
||||
pickerDescription?: string;
|
||||
foldersOnly?: boolean;
|
||||
handleGetListing?: (path?: string) => Promise<FilesystemListingResponseDto>;
|
||||
};
|
||||
|
||||
let {
|
||||
value = $bindable(""),
|
||||
title,
|
||||
description,
|
||||
placeholder = "/path/to/folder",
|
||||
pickerTitle = "Choose folder",
|
||||
pickerDescription,
|
||||
foldersOnly = true,
|
||||
handleGetListing,
|
||||
}: Props = $props();
|
||||
|
||||
const browse = () =>
|
||||
modalManager.open(PathPickerModal, {
|
||||
title: pickerTitle,
|
||||
description: pickerDescription,
|
||||
foldersOnly,
|
||||
single: true,
|
||||
handleGetListing,
|
||||
onSubmit: ([path]) => {
|
||||
if (path) {
|
||||
value = path;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Stack gap={2}>
|
||||
{#if title || description}
|
||||
<Stack gap={0}>
|
||||
{#if title}
|
||||
<Heading class="px-1" size="tiny">{@render title()}</Heading>
|
||||
{/if}
|
||||
{#if description}
|
||||
<Text color="secondary" size="small">{@render description()}</Text>
|
||||
{/if}
|
||||
</Stack>
|
||||
{/if}
|
||||
<HStack gap={2}>
|
||||
<Input bind:value {placeholder} />
|
||||
<IconButton
|
||||
icon={mdiFolder}
|
||||
aria-label="Choose folder"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
onclick={browse}
|
||||
/>
|
||||
</HStack>
|
||||
</Stack>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import StackList from "$lib/components/ui/StackList.svelte";
|
||||
import type { FilesystemListingResponseDto } from "$lib/fetch-client";
|
||||
import { handleGetFileListing } from "$lib/services/filesystem.service";
|
||||
import {
|
||||
Button,
|
||||
FormModal,
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
ListButton,
|
||||
LoadingSpinner,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@immich/ui";
|
||||
import {
|
||||
mdiArrowUp,
|
||||
mdiClose,
|
||||
mdiFileOutline,
|
||||
mdiFolderOutline,
|
||||
mdiPlus,
|
||||
} from "@mdi/js";
|
||||
import { onMount } from "svelte";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
initial?: string[];
|
||||
foldersOnly?: boolean;
|
||||
single?: boolean;
|
||||
handleGetListing?: (path?: string) => Promise<FilesystemListingResponseDto>;
|
||||
onSubmit: (paths: string[]) => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
title,
|
||||
description,
|
||||
initial = [],
|
||||
foldersOnly = false,
|
||||
single = false,
|
||||
handleGetListing,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const selected = new SvelteSet(initial);
|
||||
let listing: FilesystemListingResponseDto | undefined = $state();
|
||||
|
||||
const browse = async (path?: string) => {
|
||||
listing = undefined;
|
||||
listing = await (handleGetListing ?? handleGetFileListing)(path);
|
||||
};
|
||||
|
||||
onMount(() => void browse());
|
||||
|
||||
const sortedItems = $derived(
|
||||
listing
|
||||
? [...listing.items].sort(
|
||||
(a, b) =>
|
||||
Number(b.isDirectory) - Number(a.isDirectory) ||
|
||||
a.path.localeCompare(b.path),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await onSubmit([...selected]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const add = async (path: string) => {
|
||||
if (single) {
|
||||
await onSubmit([path]);
|
||||
onClose();
|
||||
} else {
|
||||
selected.add(path);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
{title}
|
||||
size="large"
|
||||
submitText="Save"
|
||||
onSubmit={handleSubmit}
|
||||
{onClose}
|
||||
>
|
||||
<Stack gap={5}>
|
||||
{#if description}
|
||||
<Text color="secondary">{description}</Text>
|
||||
{/if}
|
||||
|
||||
{#if !single}
|
||||
{#if selected.size > 0}
|
||||
<StackList>
|
||||
{#snippet title()}
|
||||
Selected paths
|
||||
{/snippet}
|
||||
{#each [...selected] as path (path)}
|
||||
<HStack gap={2} class="items-center px-4 py-3">
|
||||
<Text class="grow truncate" title={path}>{path}</Text>
|
||||
<IconButton
|
||||
icon={mdiClose}
|
||||
aria-label="Remove"
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
onclick={() => selected.delete(path)}
|
||||
/>
|
||||
</HStack>
|
||||
{/each}
|
||||
</StackList>
|
||||
{:else}
|
||||
<Stack gap={2}>
|
||||
<Heading class="px-1" size="tiny">Selected paths</Heading>
|
||||
<Text color="muted" class="text-center py-6">No paths selected</Text>
|
||||
</Stack>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !listing}
|
||||
<Stack gap={2}>
|
||||
<Heading class="px-1" size="tiny">Browse</Heading>
|
||||
<div class="py-6 flex justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Stack>
|
||||
{:else}
|
||||
<StackList>
|
||||
{#snippet title()}
|
||||
Browse
|
||||
{/snippet}
|
||||
|
||||
<HStack gap={2} class="items-center px-4 py-3 bg-subtle">
|
||||
<Icon icon={mdiFolderOutline} color="primary" />
|
||||
<Text class="grow truncate" title={listing.path}>
|
||||
{listing.path}
|
||||
</Text>
|
||||
{#if listing.parent && listing.parent !== listing.path}
|
||||
<IconButton
|
||||
icon={mdiArrowUp}
|
||||
aria-label="Go up"
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
onclick={() => browse(listing!.parent)}
|
||||
/>
|
||||
{/if}
|
||||
</HStack>
|
||||
|
||||
{#each sortedItems as item (item.path)}
|
||||
{#if item.isDirectory || !foldersOnly}
|
||||
<HStack gap={2} class="items-center px-2 py-1">
|
||||
{#if item.isDirectory}
|
||||
<ListButton
|
||||
leadingIcon={mdiFolderOutline}
|
||||
onclick={() => browse(item.path)}
|
||||
class="flex-1 justify-start"
|
||||
>
|
||||
{item.path.split(/[\\/]/).pop()}
|
||||
</ListButton>
|
||||
{:else}
|
||||
<HStack gap={2} class="items-center grow px-2 py-2">
|
||||
<Icon icon={mdiFileOutline} color="secondary" />
|
||||
<Text
|
||||
class="grow truncate"
|
||||
color="secondary"
|
||||
title={item.path}
|
||||
>
|
||||
{item.path.split(/[\\/]/).pop()}
|
||||
</Text>
|
||||
</HStack>
|
||||
{/if}
|
||||
<IconButton
|
||||
icon={mdiPlus}
|
||||
aria-label={single ? "Select" : "Add"}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
disabled={!single && selected.has(item.path)}
|
||||
onclick={() => add(item.path)}
|
||||
/>
|
||||
</HStack>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if sortedItems.filter((item) => item.isDirectory || !foldersOnly).length === 0}
|
||||
{#if foldersOnly}
|
||||
<Stack class="items-center px-4 py-6">
|
||||
<Button variant="outline" onclick={() => add(listing!.path)}>
|
||||
Use this folder
|
||||
</Button>
|
||||
</Stack>
|
||||
{:else}
|
||||
<Text color="muted" class="text-center py-6">
|
||||
This folder is empty
|
||||
</Text>
|
||||
{/if}
|
||||
{/if}
|
||||
</StackList>
|
||||
{/if}
|
||||
</Stack>
|
||||
</FormModal>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { getReadableErrorMessage } from "$lib/utils/handle-error";
|
||||
import { Alert, Heading, LoadingSpinner, Stack } from "@immich/ui";
|
||||
import type { CreateQueryResult } from "@tanstack/svelte-query";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type Props = {
|
||||
title?: Snippet;
|
||||
query?: CreateQueryResult<T>;
|
||||
children: Snippet<[T]>;
|
||||
};
|
||||
|
||||
const { title, query, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Stack gap={2}>
|
||||
{#if title}
|
||||
<Heading class="px-1" size="tiny">{@render title()}</Heading>
|
||||
{/if}
|
||||
|
||||
{#if query?.isLoading}
|
||||
<LoadingSpinner />
|
||||
{:else if query?.isError}
|
||||
<Alert color="danger">{getReadableErrorMessage(query.error)}</Alert>
|
||||
{:else}
|
||||
<Stack gap={0} class="divide-y rounded-2xl border overflow-hidden">
|
||||
{@render children(query?.data as T)}
|
||||
</Stack>
|
||||
{/if}
|
||||
</Stack>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { hasActions } from "$lib/utils/actions";
|
||||
import { ContextMenuButton, HStack, type ActionItem } from "@immich/ui";
|
||||
import { mdiDotsVertical } from "@mdi/js";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type Props = {
|
||||
class?: string;
|
||||
icon?: Snippet;
|
||||
children: Snippet;
|
||||
trailing?: Snippet;
|
||||
actions?: ActionItem[];
|
||||
onclick?: () => void;
|
||||
};
|
||||
|
||||
const {
|
||||
class: className,
|
||||
icon,
|
||||
children,
|
||||
trailing,
|
||||
actions = [],
|
||||
onclick,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet body()}
|
||||
<HStack gap={4} class={`${className} items-center px-4 py-3`}>
|
||||
{#if icon}
|
||||
<div
|
||||
class="flex p-2 items-center justify-center rounded-lg bg-subtle text-lg"
|
||||
>
|
||||
{@render icon?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<HStack gap={1} class="flex-1 items-baseline">
|
||||
{@render children()}
|
||||
</HStack>
|
||||
|
||||
{@render trailing?.()}
|
||||
|
||||
{#if hasActions(actions)}
|
||||
<ContextMenuButton
|
||||
icon={mdiDotsVertical}
|
||||
aria-label="Options"
|
||||
items={actions}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
/>
|
||||
{/if}
|
||||
</HStack>
|
||||
{/snippet}
|
||||
|
||||
{#if onclick}
|
||||
<button
|
||||
{onclick}
|
||||
type="button"
|
||||
class="block w-full text-left rounded-lg hover:bg-subtle focus-visible:outline-2 focus-visible:outline-primary-500 cursor-pointer"
|
||||
>
|
||||
{@render body()}
|
||||
</button>
|
||||
{:else}
|
||||
{@render body()}
|
||||
{/if}
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts" generics="T extends object">
|
||||
import { getReadableErrorMessage } from "$lib/utils/handle-error";
|
||||
import { Alert, LoadingSpinner } from "@immich/ui";
|
||||
import type { CreateQueryResult } from "@tanstack/svelte-query";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
type Props = {
|
||||
query: CreateQueryResult<T>;
|
||||
children: Snippet<[T]>;
|
||||
};
|
||||
|
||||
const { query, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if query.isLoading}
|
||||
<LoadingSpinner />
|
||||
{:else if query.isError}
|
||||
<Alert color="danger">{getReadableErrorMessage(query.error)}</Alert>
|
||||
{:else if query.isSuccess}
|
||||
{@render children(query.data)}
|
||||
{/if}
|
||||
@@ -126,10 +126,19 @@ export type RepositoryCreateResponseDto = {
|
||||
export type RepositoryListResponseDto = {
|
||||
repositories: LocalRepositoryDto[];
|
||||
};
|
||||
export type SnapshotSummaryDto = {
|
||||
filesNew: number;
|
||||
filesChanged: number;
|
||||
filesUnmodified: number;
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
dataAdded: number;
|
||||
};
|
||||
export type SnapshotDto = {
|
||||
id: string;
|
||||
time: string;
|
||||
paths: string[];
|
||||
summary?: SnapshotSummaryDto;
|
||||
};
|
||||
export type InspectedLocalRepositoryDto = {
|
||||
id: string;
|
||||
@@ -157,12 +166,15 @@ export type RepositoryCheckImportResponseDto = {
|
||||
readable: boolean;
|
||||
};
|
||||
export type RunStatus = "incomplete" | "complete" | "failed";
|
||||
export type RunType = "backup" | "restore";
|
||||
export type RunDto = {
|
||||
id: string;
|
||||
repositoryId: string;
|
||||
start: string;
|
||||
end: string;
|
||||
logFilePath: string;
|
||||
status: RunStatus;
|
||||
"type": RunType;
|
||||
};
|
||||
export type RunHistoryResponseDto = {
|
||||
runs: RunDto[];
|
||||
@@ -178,6 +190,9 @@ export type RepositorySnapshotRestoreFromPointRequestDto = {
|
||||
yuccaConfig?: string;
|
||||
include?: string[];
|
||||
};
|
||||
export type RunResponseDto = {
|
||||
run: RunDto;
|
||||
};
|
||||
export type TaskType = "schedule" | "restore" | "backup" | "forget";
|
||||
export type TaskStatus = "incomplete" | "complete" | "failed";
|
||||
export type ActiveScheduleItemDto = {
|
||||
@@ -455,8 +470,16 @@ export function getSnapshotListing(id: string, snapshot: string, { path }: {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
export function getRun(id: string, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: RunResponseDto;
|
||||
}>(`/api/yucca/logs/${encodeURIComponent(id)}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
export function logStreamSse(id: string, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/api/yucca/logs/${encodeURIComponent(id)}`, {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/api/yucca/logs/${encodeURIComponent(id)}/stream`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
// Reexport your entry components here
|
||||
export { default as BackendsList } from './components/backends/BackendsList.svelte';
|
||||
export { default as BackupsList } from './components/backups/BackupsList.svelte';
|
||||
export { default as Dashboard } from './components/dashboard/Dashboard.svelte';
|
||||
export { default as OnboardingGate } from './components/onboarding/OnboardingGate.svelte';
|
||||
export { default as ScheduleList } from './components/schedules/ScheduleList.svelte';
|
||||
export { default as YuccaContext } from './components/util/YuccaContext.svelte';
|
||||
export { default as OnEvents } from './components/util/OnEvents.svelte';
|
||||
export { default as ImmichBackupsPage } from './components/integrations/immich/ImmichBackupsPage.svelte';
|
||||
export { default as ImmichOnboardingSetupFlow } from './components/integrations/immich/ImmichOnboardingSetupFlow.svelte';
|
||||
export { default as ImmichOnboardingRestoreFlow } from './components/integrations/immich/ImmichOnboardingRestoreFlow.svelte';
|
||||
export { default as ImmichManageBackup } from './components/integrations/immich/ImmichManageBackup.svelte';
|
||||
export * from './providers';
|
||||
|
||||
export * as sdk from './fetch-client';
|
||||
|
||||
export * from './services/backend.service';
|
||||
export * from './services/filesystem.service';
|
||||
export * from './services/integrations.service';
|
||||
export * from './services/log.service.svelte';
|
||||
export * from './services/onboarding.service';
|
||||
export * from './services/repository.service';
|
||||
export * from './services/runHistory.service';
|
||||
export * from './services/schedule.service';
|
||||
export * from './services/snapshot.service';
|
||||
export * from './services/task.service';
|
||||
|
||||
export { events, SocketEvent } from './events';
|
||||
export { queryClient } from './query-client';
|
||||
|
||||
@@ -2,4 +2,6 @@ import { writable } from 'svelte/store';
|
||||
|
||||
export const options = {
|
||||
advanced: writable(false),
|
||||
testUiRestore: writable(false),
|
||||
demoPadding: writable(false),
|
||||
};
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import CreateLocalBackend from '$lib/components/backends/CreateLocalBackend.svelte';
|
||||
import OAuthDeviceFlow from '$lib/components/backends/OAuthDeviceFlow.svelte';
|
||||
import { SocketEvent } from '$lib/events';
|
||||
import {
|
||||
createLocalBackend,
|
||||
getBackends,
|
||||
oidcDeviceFlow,
|
||||
type BackendDto,
|
||||
type CreateLocalBackendRequestDto,
|
||||
getBackends,
|
||||
} from '$lib/fetch-client';
|
||||
import { SocketEvent } from '$lib/events';
|
||||
import { queryClient } from '$lib/query-client';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { modalManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiLogin } from '@mdi/js';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { oidcDeviceFlow } from '$lib/fetch-client';
|
||||
|
||||
export const backendKeys = {
|
||||
all: ['backends'] as const,
|
||||
@@ -41,11 +45,27 @@ export const useBackendEventHandler = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export async function handleYuccaLogin() {
|
||||
const response = await oidcDeviceFlow();
|
||||
window.open(response.verificationUri, '_blank');
|
||||
return response;
|
||||
}
|
||||
export const handleYuccaLogin = async (
|
||||
onCreate?: (backendId: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const response = await oidcDeviceFlow();
|
||||
void modalManager.show(OAuthDeviceFlow, {
|
||||
...response,
|
||||
onCreate,
|
||||
});
|
||||
window.open(response.verificationUri, '_blank');
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to start login');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleSetupLocalStorage = (
|
||||
onCreate?: (backendId: string) => void,
|
||||
) => {
|
||||
void modalManager.show(CreateLocalBackend, { onCreate });
|
||||
};
|
||||
|
||||
export const handleCreateLocalBackend = async (
|
||||
dto: CreateLocalBackendRequestDto,
|
||||
@@ -57,3 +77,14 @@ export const handleCreateLocalBackend = async (
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getBackendActions = (backend: BackendDto) => {
|
||||
const LoginAgain: ActionItem = {
|
||||
icon: mdiLogin,
|
||||
title: 'Login again',
|
||||
onAction: () => void handleYuccaLogin(),
|
||||
$if: () => backend.type === 'yucca' && !backend.isOnline,
|
||||
};
|
||||
|
||||
return { LoginAgain };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import ImmichConfigureBackup from '$lib/components/integrations/immich/ImmichConfigureBackup.svelte';
|
||||
import { modalManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiCloudUploadOutline, mdiCogOutline, mdiKeyOutline } from '@mdi/js';
|
||||
import { handleCreateBackup } from './repository.service';
|
||||
|
||||
export const getBackupPageActions = (repositoryId?: string) => {
|
||||
const Configure: ActionItem = {
|
||||
title: 'Configure',
|
||||
icon: mdiCogOutline,
|
||||
onAction: () => modalManager.show(ImmichConfigureBackup, {}),
|
||||
};
|
||||
|
||||
const ViewRecoveryKey: ActionItem = {
|
||||
title: 'View recovery key',
|
||||
icon: mdiKeyOutline,
|
||||
onAction: () => void 0,
|
||||
// onAction: () => modalManager.show(BackupsRecoveryKeyModal, {}),
|
||||
};
|
||||
|
||||
const BackUpNow: ActionItem = {
|
||||
title: 'Back up now',
|
||||
icon: mdiCloudUploadOutline,
|
||||
onAction: () => void handleCreateBackup(repositoryId!),
|
||||
$if: () => typeof repositoryId === 'string',
|
||||
};
|
||||
|
||||
return { Configure, ViewRecoveryKey, BackUpNow };
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { SocketEvent } from '$lib/events';
|
||||
import {
|
||||
configureImmichIntegration,
|
||||
getIntegrations,
|
||||
type IntegrationsResponseDto,
|
||||
} from '$lib/fetch-client';
|
||||
import { SocketEvent } from '$lib/events';
|
||||
import { queryClient } from '$lib/query-client';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createMutation, createQuery } from '@tanstack/svelte-query';
|
||||
|
||||
export const integrationsKeys = {
|
||||
all: ['integrations'] as const,
|
||||
@@ -26,3 +28,29 @@ export const useIntegrationEventHandler = () => ({
|
||||
queryClient.setQueryData(integrationsKeys.all, event.data.integrations);
|
||||
},
|
||||
});
|
||||
|
||||
export const useConfigureImmichDefaults = () =>
|
||||
createMutation(
|
||||
() => ({
|
||||
mutationFn: async () => {
|
||||
const { immichState } = await getIntegrations();
|
||||
if (!immichState) {
|
||||
throw new Error('No Immich instance detected.');
|
||||
}
|
||||
|
||||
await configureImmichIntegration({
|
||||
name: 'Immich',
|
||||
worm: false,
|
||||
cron: '0 3 * * *',
|
||||
dataFolders: immichState.dataFolders,
|
||||
backupConfiguration: true,
|
||||
libraries: 'all',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: integrationsKeys.all });
|
||||
},
|
||||
onError: (error) => handleError(error, 'Failed to create backup'),
|
||||
}),
|
||||
() => queryClient,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import { defaults } from '$lib/fetch-client';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
type SummaryEvent = {
|
||||
message_type: 'summary';
|
||||
// backup
|
||||
files_new?: number;
|
||||
files_changed?: number;
|
||||
files_unmodified?: number;
|
||||
data_added?: number;
|
||||
total_files_processed?: number;
|
||||
total_bytes_processed?: number;
|
||||
total_duration?: number;
|
||||
snapshot_id?: string;
|
||||
// restore
|
||||
files_restored?: number;
|
||||
files_skipped?: number;
|
||||
files_deleted?: number;
|
||||
total_files?: number;
|
||||
bytes_restored?: number;
|
||||
bytes_skipped?: number;
|
||||
total_bytes?: number;
|
||||
seconds_elapsed?: number;
|
||||
};
|
||||
|
||||
type LogEvent =
|
||||
| { message_type: 'summary' }
|
||||
| SummaryEvent
|
||||
| {
|
||||
message_type: 'error';
|
||||
error?: string | { message: string };
|
||||
@@ -41,6 +63,7 @@ export function createLogObserver(logId: string) {
|
||||
status: LogStatus;
|
||||
errors: string[];
|
||||
events: LogEvent[];
|
||||
summary: SummaryEvent | undefined;
|
||||
}>({
|
||||
status: {
|
||||
progress: 0,
|
||||
@@ -49,6 +72,7 @@ export function createLogObserver(logId: string) {
|
||||
},
|
||||
errors: [],
|
||||
events: [],
|
||||
summary: undefined,
|
||||
});
|
||||
|
||||
const buffer: LogEvent[] = [];
|
||||
@@ -64,8 +88,10 @@ export function createLogObserver(logId: string) {
|
||||
);
|
||||
|
||||
const onEvent = (event: LogEvent) => {
|
||||
buffer.unshift(event);
|
||||
buffer.splice(50);
|
||||
if (event.message_type !== 'status') {
|
||||
buffer.unshift(event);
|
||||
buffer.splice(50);
|
||||
}
|
||||
|
||||
switch (event.message_type) {
|
||||
case 'status': {
|
||||
@@ -80,6 +106,7 @@ export function createLogObserver(logId: string) {
|
||||
break;
|
||||
}
|
||||
case 'summary': {
|
||||
state.summary = event;
|
||||
state.status = {
|
||||
progress: 1,
|
||||
text: '',
|
||||
@@ -107,7 +134,7 @@ export function createLogObserver(logId: string) {
|
||||
};
|
||||
|
||||
const source = new EventSource(
|
||||
new URL(`/api/yucca/logs/${logId}`, defaults.baseUrl),
|
||||
new URL(`/api/yucca/logs/${logId}/stream`, defaults.baseUrl),
|
||||
);
|
||||
source.addEventListener('message', ({ data }) => onEvent(JSON.parse(data)));
|
||||
|
||||
@@ -121,6 +148,9 @@ export function createLogObserver(logId: string) {
|
||||
get events() {
|
||||
return state.events;
|
||||
},
|
||||
get summary() {
|
||||
return state.summary;
|
||||
},
|
||||
destroy() {
|
||||
flush.cancel();
|
||||
source.close();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sdk } from '$lib';
|
||||
import ViewLogModal from '$lib/components/backups/dialogs/ViewLogModal.svelte';
|
||||
import {
|
||||
deleteRepository,
|
||||
inspectRepositories,
|
||||
@@ -12,7 +13,7 @@ import { SocketEvent } from '$lib/events';
|
||||
import { getProvider } from '$lib/providers';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { queryClient } from '$lib/query-client';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { modalManager, toastManager } from '@immich/ui';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
|
||||
export const repositoryKeys = {
|
||||
@@ -127,7 +128,9 @@ export const handleCreateRepository = async (
|
||||
export const handleCreateBackup = async (id: string) => {
|
||||
try {
|
||||
toastManager.info('Started backup');
|
||||
return await sdk.createBackup(id);
|
||||
const response = await sdk.createBackup(id);
|
||||
void modalManager.open(ViewLogModal, { logId: response.logId });
|
||||
return response;
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to start backup');
|
||||
throw error;
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { sdk } from '$lib';
|
||||
import { getRunHistory } from '$lib/fetch-client';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import ViewLogModal from '$lib/components/backups/dialogs/ViewLogModal.svelte';
|
||||
import { SocketEvent } from '$lib/events';
|
||||
import { getRun, getRunHistory, type RunDto } from '$lib/fetch-client';
|
||||
import { queryClient } from '$lib/query-client';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { modalManager, type ActionItem } from '@immich/ui';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
|
||||
export const runHistoryKeys = {
|
||||
byRepository: (id: string) => ['runHistory', id] as const,
|
||||
};
|
||||
|
||||
export const runKeys = {
|
||||
byId: (id: string) => ['run', id] as const,
|
||||
};
|
||||
|
||||
export const useRunHistory = (repositoryId: string) =>
|
||||
createQuery(
|
||||
() => ({
|
||||
@@ -20,6 +27,52 @@ export const useRunHistory = (repositoryId: string) =>
|
||||
() => queryClient,
|
||||
);
|
||||
|
||||
export const useRun = (logId: string) =>
|
||||
createQuery(
|
||||
() => ({
|
||||
queryKey: runKeys.byId(logId),
|
||||
queryFn: () => getRun(logId).then(({ run }) => run),
|
||||
}),
|
||||
() => queryClient,
|
||||
);
|
||||
|
||||
export const useRunEventHandler = () => {
|
||||
return {
|
||||
onRunCreate(event: SocketEvent<{ run: RunDto }>) {
|
||||
queryClient.setQueryData(runKeys.byId(event.data.run.id), event.data.run);
|
||||
queryClient.setQueryData(
|
||||
runHistoryKeys.byRepository(event.data.run.repositoryId),
|
||||
(data: RunDto[] | undefined) =>
|
||||
data ? [event.data.run, ...data] : void 0,
|
||||
);
|
||||
},
|
||||
onRunUpdate(
|
||||
event: SocketEvent<{
|
||||
runId: string;
|
||||
repositoryId: string;
|
||||
run: Partial<RunDto>;
|
||||
}>,
|
||||
) {
|
||||
queryClient.setQueryData(
|
||||
runKeys.byId(event.data.runId),
|
||||
(data: RunDto | undefined) =>
|
||||
data ? { ...data, ...event.data.run } : void 0,
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
runHistoryKeys.byRepository(event.data.repositoryId),
|
||||
(data: RunDto[] | undefined) =>
|
||||
data
|
||||
? data.map((entry) =>
|
||||
entry.id === event.data.runId
|
||||
? { ...entry, ...event.data.run }
|
||||
: entry,
|
||||
)
|
||||
: void 0,
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const handleGetRunHistory = async (id: string) => {
|
||||
try {
|
||||
return await sdk.getRunHistory(id);
|
||||
@@ -28,3 +81,12 @@ export const handleGetRunHistory = async (id: string) => {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRunActions = (run: RunDto) => {
|
||||
const ViewLog: ActionItem = {
|
||||
title: 'View Log',
|
||||
onAction: () => void modalManager.open(ViewLogModal, { logId: run.id }),
|
||||
};
|
||||
|
||||
return { ViewLog };
|
||||
};
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { sdk } from '$lib';
|
||||
import RestoreSnapshotModal from '$lib/components/backups/dialogs/RestoreSnapshotModal.svelte';
|
||||
import { SocketEvent } from '$lib/events';
|
||||
import {
|
||||
getSnapshots,
|
||||
type RepositorySnapshotRestoreFromPointRequestDto,
|
||||
type RepositorySnapshotRestoreRequestDto,
|
||||
type RunDto,
|
||||
type SnapshotDto,
|
||||
} from '$lib/fetch-client';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { queryClient } from '$lib/query-client';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiBackupRestore, mdiDeleteOutline } from '@mdi/js';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
|
||||
export const snapshotKeys = {
|
||||
@@ -26,6 +30,24 @@ export const useSnapshots = (repositoryId: string) =>
|
||||
() => queryClient,
|
||||
);
|
||||
|
||||
export const useSnapshotEventHandler = () => {
|
||||
return {
|
||||
onRunUpdate(
|
||||
event: SocketEvent<{
|
||||
runId: string;
|
||||
repositoryId: string;
|
||||
run: Partial<RunDto>;
|
||||
}>,
|
||||
) {
|
||||
if (event.data.run.status === 'complete') {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: snapshotKeys.byRepository(event.data.repositoryId),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useRemoveSnapshot = (repositoryId: string) => {
|
||||
return (snapshotId: string) => {
|
||||
queryClient.setQueryData(
|
||||
@@ -103,3 +125,40 @@ export const handleGetSnapshotListing = async (
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSnapshotActions = (
|
||||
repositoryId: string,
|
||||
snapshot: SnapshotDto,
|
||||
) => {
|
||||
const removeSnapshot = useRemoveSnapshot(repositoryId);
|
||||
|
||||
const Restore: ActionItem = {
|
||||
title: 'Restore',
|
||||
icon: mdiBackupRestore,
|
||||
onAction: () =>
|
||||
void modalManager.open(RestoreSnapshotModal, {
|
||||
repository: repositoryId,
|
||||
snapshot: snapshot.id,
|
||||
}),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
title: 'Delete',
|
||||
icon: mdiDeleteOutline,
|
||||
color: 'danger',
|
||||
onAction: async () => {
|
||||
const confirm = await modalManager.showDialog({
|
||||
confirmText: 'Delete',
|
||||
title: 'Delete Snapshot',
|
||||
prompt: 'This snapshot will be permanently removed.',
|
||||
});
|
||||
|
||||
if (!confirm) {return;}
|
||||
|
||||
await handleForgetSnapshot(repositoryId, snapshot.id);
|
||||
removeSnapshot(snapshot.id);
|
||||
},
|
||||
};
|
||||
|
||||
return { Restore, Delete };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ActionItem } from '@immich/ui';
|
||||
|
||||
export function hasActions(actions: ActionItem[]) {
|
||||
return actions.some((action) => !action.$if || action.$if?.());
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { MockProvider, setProvider } from "$lib/providers";
|
||||
import { toastManager } from "@immich/ui";
|
||||
import { initializeTheme, theme, Theme, toastManager } from "@immich/ui";
|
||||
import { YuccaContext } from "$lib";
|
||||
import "./layout.css";
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
|
||||
setProvider(new MockProvider());
|
||||
|
||||
theme.value = Theme.Light;
|
||||
initializeTheme();
|
||||
|
||||
toastManager.setOptions({
|
||||
class: "fixed top-0 right-0 flex flex-col items-end justify-end gap-2 p-4",
|
||||
});
|
||||
</script>
|
||||
|
||||
<YuccaContext>
|
||||
{@render children()}
|
||||
</YuccaContext>
|
||||
<div class="bg-light text-dark min-h-dvh">
|
||||
<YuccaContext>
|
||||
{@render children()}
|
||||
</YuccaContext>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
<script>
|
||||
import { Button, Checkbox, Heading, HStack, Text } from "@immich/ui";
|
||||
import TestUi from "$lib/components/scraps/TestUi.svelte";
|
||||
import { options } from "$lib/options";
|
||||
<script lang="ts">
|
||||
import OnboardingGate from "$lib/components/onboarding/OnboardingGate.svelte";
|
||||
import ImmichTestUi from "$lib/components/scraps/ImmichTestUi.svelte";
|
||||
import TestUi from "$lib/components/scraps/TestUi.svelte";
|
||||
import { resetOrchestrator } from "$lib/fetch-client";
|
||||
import { options } from "$lib/options";
|
||||
import { Button, Checkbox, Heading, HStack, Stack, Text } from "@immich/ui";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let mock = $state(false);
|
||||
let mock: true | false | "immich" = $state(false);
|
||||
|
||||
const { advanced } = options;
|
||||
const { advanced, testUiRestore } = options;
|
||||
|
||||
async function onReset() {
|
||||
await resetOrchestrator();
|
||||
location.reload();
|
||||
}
|
||||
|
||||
onMount(
|
||||
() =>
|
||||
(mock = JSON.parse(localStorage.getItem("mock") ?? "{}").mock ?? true),
|
||||
);
|
||||
|
||||
$effect(() => localStorage.setItem("mock", JSON.stringify({ mock })));
|
||||
</script>
|
||||
|
||||
<div class="p-8 flex flex-col gap-4">
|
||||
<div class="p-8 flex items-center justify-between gap-4 bg-gray-100 text-black">
|
||||
<Heading size="giant"
|
||||
>Orchestrator <img
|
||||
alt="Test UI"
|
||||
@@ -24,28 +33,41 @@
|
||||
/></Heading
|
||||
>
|
||||
|
||||
<HStack>
|
||||
<Button onclick={() => (mock = true)} disabled={mock}
|
||||
>Use mock provider</Button
|
||||
>
|
||||
<Button onclick={() => (mock = false)} disabled={!mock}
|
||||
>Use orchestration API</Button
|
||||
>
|
||||
<Button onclick={onReset} color="warning">Reset</Button>
|
||||
</HStack>
|
||||
<Stack align="end">
|
||||
<HStack>
|
||||
<Button onclick={() => (mock = true)} disabled={mock === true}
|
||||
>Use mock provider</Button
|
||||
>
|
||||
<Button onclick={() => (mock = false)} disabled={mock === false}
|
||||
>Use orchestration API</Button
|
||||
>
|
||||
<Button onclick={() => (mock = "immich")} disabled={mock === "immich"}
|
||||
>Immich</Button
|
||||
>
|
||||
<Button onclick={onReset} color="warning">Reset</Button>
|
||||
</HStack>
|
||||
|
||||
<label class="select-none flex gap-2 items-center">
|
||||
<Checkbox bind:checked={$advanced} />
|
||||
<Text size="giant">Show advanced options</Text>
|
||||
</label>
|
||||
|
||||
<hr />
|
||||
|
||||
{#if mock}
|
||||
<TestUi {mock} />
|
||||
{:else}
|
||||
<OnboardingGate flow="default" onExit={() => (mock = false)}>
|
||||
<TestUi {mock} />
|
||||
</OnboardingGate>
|
||||
{/if}
|
||||
<HStack gap={4}>
|
||||
<label class="select-none flex gap-2 items-center">
|
||||
<Checkbox bind:checked={$advanced} />
|
||||
<Text size="giant">Show advanced options</Text>
|
||||
</label>
|
||||
<label class="select-none flex gap-2 items-center">
|
||||
<Checkbox bind:checked={$testUiRestore} />
|
||||
<Text size="giant">Immich restore mode</Text>
|
||||
</label>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{#if mock === "immich"}
|
||||
<ImmichTestUi onExit={() => (mock = true)} />
|
||||
{:else if mock}
|
||||
<TestUi mock={true} />
|
||||
{:else}
|
||||
<OnboardingGate onExit={() => (mock = true)}>
|
||||
<TestUi mock={false} />
|
||||
</OnboardingGate>
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
@import "@immich/ui/theme/default.css";
|
||||
@source "../../node_modules/@immich/ui";
|
||||
|
||||
body {
|
||||
background-color: var(--color-light);
|
||||
color: var(--color-dark);
|
||||
}
|
||||
|
||||
Generated
+9
-319
@@ -612,9 +612,9 @@ importers:
|
||||
cron-validate:
|
||||
specifier: ^1.5.3
|
||||
version: 1.5.3
|
||||
d3:
|
||||
specifier: ^7.9.0
|
||||
version: 7.9.0
|
||||
cronstrue:
|
||||
specifier: ^3.14.0
|
||||
version: 3.14.0
|
||||
lodash.debounce:
|
||||
specifier: ^4.0.8
|
||||
version: 4.0.8
|
||||
@@ -3942,10 +3942,6 @@ packages:
|
||||
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
commander@7.2.0:
|
||||
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
comment-json@4.4.1:
|
||||
resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -4030,137 +4026,14 @@ packages:
|
||||
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
|
||||
engines: {node: '>=18.x'}
|
||||
|
||||
cronstrue@3.14.0:
|
||||
resolution: {integrity: sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==}
|
||||
hasBin: true
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
d3-array@3.2.4:
|
||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-axis@3.0.0:
|
||||
resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-brush@3.0.0:
|
||||
resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-chord@3.0.1:
|
||||
resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-contour@4.0.2:
|
||||
resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-delaunay@6.0.4:
|
||||
resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dsv@3.0.1:
|
||||
resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-fetch@3.0.1:
|
||||
resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-force@3.0.0:
|
||||
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-geo@3.1.1:
|
||||
resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-hierarchy@3.1.2:
|
||||
resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-polygon@3.0.1:
|
||||
resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-quadtree@3.0.1:
|
||||
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-random@3.0.1:
|
||||
resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale-chromatic@3.1.0:
|
||||
resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time@3.1.0:
|
||||
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-transition@3.0.1:
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3@7.9.0:
|
||||
resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
date-fns@3.6.0:
|
||||
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
|
||||
|
||||
@@ -4208,9 +4081,6 @@ packages:
|
||||
defaults@1.0.4:
|
||||
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
|
||||
|
||||
delaunator@5.0.1:
|
||||
resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -4762,10 +4632,6 @@ packages:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
engines: {node: '>=10.17.0'}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.7.1:
|
||||
resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4817,10 +4683,6 @@ packages:
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -5994,9 +5856,6 @@ packages:
|
||||
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
robust-predicates@3.0.2:
|
||||
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
|
||||
|
||||
rollup@4.55.1:
|
||||
resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -6020,9 +5879,6 @@ packages:
|
||||
'@sveltejs/kit':
|
||||
optional: true
|
||||
|
||||
rw@1.3.3:
|
||||
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
|
||||
|
||||
rxjs@7.8.1:
|
||||
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
|
||||
|
||||
@@ -11276,8 +11132,6 @@ snapshots:
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
commander@7.2.0: {}
|
||||
|
||||
comment-json@4.4.1:
|
||||
dependencies:
|
||||
array-timsort: 1.0.3
|
||||
@@ -11351,164 +11205,14 @@ snapshots:
|
||||
'@types/luxon': 3.7.1
|
||||
luxon: 3.7.2
|
||||
|
||||
cronstrue@3.14.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
d3-array@3.2.4:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-axis@3.0.0: {}
|
||||
|
||||
d3-brush@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
d3-chord@3.0.1:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-contour@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-delaunay@6.0.4:
|
||||
dependencies:
|
||||
delaunator: 5.0.1
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
|
||||
d3-dsv@3.0.1:
|
||||
dependencies:
|
||||
commander: 7.2.0
|
||||
iconv-lite: 0.6.3
|
||||
rw: 1.3.3
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-fetch@3.0.1:
|
||||
dependencies:
|
||||
d3-dsv: 3.0.1
|
||||
|
||||
d3-force@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-quadtree: 3.0.1
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-geo@3.1.1:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-hierarchy@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-polygon@3.0.1: {}
|
||||
|
||||
d3-quadtree@3.0.1: {}
|
||||
|
||||
d3-random@3.0.1: {}
|
||||
|
||||
d3-scale-chromatic@3.1.0:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-interpolate: 3.0.1
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-selection@3.0.0: {}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
dependencies:
|
||||
d3-time: 3.1.0
|
||||
|
||||
d3-time@3.1.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
d3@7.9.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-axis: 3.0.0
|
||||
d3-brush: 3.0.0
|
||||
d3-chord: 3.0.1
|
||||
d3-color: 3.1.0
|
||||
d3-contour: 4.0.2
|
||||
d3-delaunay: 6.0.4
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-dsv: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-fetch: 3.0.1
|
||||
d3-force: 3.0.0
|
||||
d3-format: 3.1.2
|
||||
d3-geo: 3.1.1
|
||||
d3-hierarchy: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-path: 3.1.0
|
||||
d3-polygon: 3.0.1
|
||||
d3-quadtree: 3.0.1
|
||||
d3-random: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-scale-chromatic: 3.1.0
|
||||
d3-selection: 3.0.0
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
d3-timer: 3.0.1
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
date-fns@3.6.0: {}
|
||||
|
||||
dateformat@4.6.3: {}
|
||||
@@ -11539,10 +11243,6 @@ snapshots:
|
||||
dependencies:
|
||||
clone: 1.0.4
|
||||
|
||||
delaunator@5.0.1:
|
||||
dependencies:
|
||||
robust-predicates: 3.0.2
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
delegates@1.0.0: {}
|
||||
@@ -12202,10 +11902,6 @@ snapshots:
|
||||
|
||||
human-signals@2.1.0: {}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.7.1:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -12250,8 +11946,6 @@ snapshots:
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
@@ -13658,8 +13352,6 @@ snapshots:
|
||||
onetime: 5.1.2
|
||||
signal-exit: 3.0.7
|
||||
|
||||
robust-predicates@3.0.2: {}
|
||||
|
||||
rollup@4.55.1:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -13750,8 +13442,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@sveltejs/kit': 2.50.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.49.2)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.49.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
|
||||
rw@1.3.3: {}
|
||||
|
||||
rxjs@7.8.1:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
Reference in New Issue
Block a user