From b0d8f2c353e832f07cace6fbad2960c1bd2672c5 Mon Sep 17 00:00:00 2001 From: Paul Makles Date: Thu, 14 May 2026 07:07:59 -0500 Subject: [PATCH] feat: immich integration UX (#87) Signed-off-by: izzy --- .vscode/settings.json | 20 ++ packages/yucca-api-client/package.json | 2 +- .../yucca-sdk/orchestration-api/bin/server.ts | 12 + .../orchestration-api/openapi-specs.json | 96 ++++- .../yucca-sdk/orchestration-api/package.json | 2 +- .../src/controllers/runHistory.controller.ts | 14 +- .../src/dto/repository.dto.ts | 37 +- .../src/events/events.gateway.ts | 12 +- .../src/repositories/restic.repository.ts | 17 +- .../src/repositories/runHistory.repository.ts | 44 ++- .../20260507120000-AddRunHistoryType.ts | 17 + .../src/schema/tables/runHistory.table.ts | 5 +- .../src/services/auth.service.ts | 2 +- .../src/services/integrations.service.ts | 2 +- .../src/services/repository.service.ts | 26 +- .../src/services/schedule.service.ts | 14 +- .../orchestration-api/src/utils/restic.ts | 26 ++ .../yucca-sdk/orchestration-ui/package.json | 4 +- .../components/backends/BackendItem.svelte | 64 ++++ .../components/backends/BackendsList.svelte | 113 +++--- .../backends/CreateLocalBackend.svelte | 40 +-- .../backends/OAuthDeviceFlow.svelte | 52 ++- .../lib/components/backups/BackupItem.svelte | 14 +- .../lib/components/backups/BackupsList.svelte | 24 +- .../dialogs/ConfigureImmichBackup.svelte | 208 ----------- .../dialogs/ConfigureRepositoryModal.svelte | 73 ++-- .../dialogs/CreateRepositoryModal.svelte | 9 +- .../backups/dialogs/FileBrowserModal.svelte | 91 ----- .../dialogs/ImportRepositoryModal.svelte | 6 +- .../dialogs/RestoreSnapshotModal.svelte | 138 +++---- .../backups/dialogs/RunHistoryModal.svelte | 67 ---- .../backups/dialogs/SnapshotsListModal.svelte | 103 ------ .../backups/dialogs/ViewLogModal.svelte | 189 ++++++++-- .../run-history/RepositoryRunHistory.svelte | 39 ++ .../RepositoryRunHistoryItem.svelte | 42 +++ .../run-history/RunHistoryModal.svelte | 18 + .../RepositorySnapshotsList.svelte | 39 ++ .../RepositorySnapshotsListItem.svelte | 41 +++ .../snapshots-list/SnapshotsListModal.svelte | 18 + .../lib/components/dashboard/Dashboard.svelte | 167 +-------- .../dashboard/ImmichIntegrationCard.svelte | 87 ----- .../dashboard/visualisations/HeatMap.svelte | 173 --------- .../visualisations/StackedBarChart.svelte | 200 ----------- .../immich/ImmichBackupsPage.svelte | 14 + .../immich/ImmichConfigureBackup.svelte | 339 ++++++++++++++++++ .../immich/ImmichConfirmDefaultBackup.svelte | 80 +++++ .../immich/ImmichManageBackup.svelte | 77 ++++ .../immich/ImmichManageBackupOverview.svelte | 100 ++++++ .../immich/ImmichOnboardingRestoreFlow.svelte | 75 ++++ .../immich/ImmichOnboardingSetupFlow.svelte | 106 ++++++ .../components/onboarding/Onboarding.svelte | 141 -------- .../onboarding/OnboardingGate.svelte | 8 +- .../onboarding/SampleOnboarding.svelte | 86 +++++ .../RestorePointFlow.svelte | 73 ++-- .../RestorePointFlow2SelectSnapshot.svelte | 39 +- .../onboarding/stages/BackupOptions.svelte | 78 ---- .../onboarding/stages/KeyIntro.svelte | 40 --- .../OnboardingStageBackupServices.svelte | 82 +++++ ...velte => OnboardingStageKeyConfirm.svelte} | 18 +- ...svelte => OnboardingStageKeyImport.svelte} | 0 .../stages/OnboardingStageKeyIntro.svelte | 50 +++ ...y.svelte => OnboardingStageKeySave.svelte} | 45 ++- ...e.svelte => OnboardingStageWelcome.svelte} | 11 +- ....svelte => SampleCreateFirstBackup.svelte} | 0 ...velte => SampleCreateFirstSchedule.svelte} | 0 .../lib/components/scraps/ImmichTestUi.svelte | 183 ++++++++++ .../src/lib/components/scraps/TestUi.svelte | 29 +- .../src/lib/components/ui/PageLayout.svelte | 67 ++++ .../lib/components/ui/PathListField.svelte | 83 +++++ .../lib/components/ui/PathPickerField.svelte | 74 ++++ .../lib/components/ui/PathPickerModal.svelte | 204 +++++++++++ .../src/lib/components/ui/StackList.svelte | 30 ++ .../lib/components/ui/StackListItem.svelte | 64 ++++ .../src/lib/components/util/Suspense.svelte | 21 ++ .../orchestration-ui/src/lib/fetch-client.ts | 25 +- .../orchestration-ui/src/lib/index.ts | 20 +- .../orchestration-ui/src/lib/options.ts | 2 + .../src/lib/services/backend.service.ts | 47 ++- .../services/immich.integration.service.ts | 28 ++ .../src/lib/services/integrations.service.ts | 32 +- .../src/lib/services/log.service.svelte.ts | 38 +- .../src/lib/services/repository.service.ts | 7 +- .../src/lib/services/runHistory.service.ts | 66 +++- .../src/lib/services/snapshot.service.ts | 63 +++- .../orchestration-ui/src/lib/utils/actions.ts | 5 + .../src/routes/+layout.svelte | 13 +- .../orchestration-ui/src/routes/+page.svelte | 82 +++-- .../orchestration-ui/src/routes/layout.css | 5 + pnpm-lock.yaml | 328 +---------------- 89 files changed, 3182 insertions(+), 2163 deletions(-) create mode 100644 packages/yucca-sdk/orchestration-api/src/schema/migrations/20260507120000-AddRunHistoryType.ts create mode 100644 packages/yucca-sdk/orchestration-api/src/utils/restic.ts create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backends/BackendItem.svelte delete mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ConfigureImmichBackup.svelte delete mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/FileBrowserModal.svelte delete mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/RunHistoryModal.svelte delete mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/SnapshotsListModal.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RepositoryRunHistory.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RepositoryRunHistoryItem.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RunHistoryModal.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/RepositorySnapshotsList.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/RepositorySnapshotsListItem.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/SnapshotsListModal.svelte delete mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/ImmichIntegrationCard.svelte delete mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/visualisations/HeatMap.svelte delete mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/visualisations/StackedBarChart.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichBackupsPage.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichConfigureBackup.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichConfirmDefaultBackup.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichManageBackup.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichManageBackupOverview.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichOnboardingRestoreFlow.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichOnboardingSetupFlow.svelte delete mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/Onboarding.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/SampleOnboarding.svelte delete mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/BackupOptions.svelte delete mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/KeyIntro.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageBackupServices.svelte rename packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/{ConfirmKey.svelte => OnboardingStageKeyConfirm.svelte} (76%) rename packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/{ImportKey.svelte => OnboardingStageKeyImport.svelte} (100%) create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeyIntro.svelte rename packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/{SaveKey.svelte => OnboardingStageKeySave.svelte} (69%) rename packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/{Welcome.svelte => OnboardingStageWelcome.svelte} (85%) rename packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/{CreateFirstBackup.svelte => SampleCreateFirstBackup.svelte} (100%) rename packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/{CreateFirstSchedule.svelte => SampleCreateFirstSchedule.svelte} (100%) create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/scraps/ImmichTestUi.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PageLayout.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathListField.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathPickerField.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathPickerModal.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/ui/StackList.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/ui/StackListItem.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/components/util/Suspense.svelte create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/services/immich.integration.service.ts create mode 100644 packages/yucca-sdk/orchestration-ui/src/lib/utils/actions.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 0dbca735..25900b1f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" }, diff --git a/packages/yucca-api-client/package.json b/packages/yucca-api-client/package.json index b1286a65..353af92e 100644 --- a/packages/yucca-api-client/package.json +++ b/packages/yucca-api-client/package.json @@ -1,6 +1,6 @@ { "name": "yucca-api-client", - "version": "0.1.63", + "version": "0.1.69", "description": "", "type": "module", "main": "dist/index.js", diff --git a/packages/yucca-sdk/orchestration-api/bin/server.ts b/packages/yucca-sdk/orchestration-api/bin/server.ts index 22c9c0fa..aa920222 100644 --- a/packages/yucca-sdk/orchestration-api/bin/server.ts +++ b/packages/yucca-sdk/orchestration-api/bin/server.ts @@ -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: [], + }, + ], + }, }), ); diff --git a/packages/yucca-sdk/orchestration-api/openapi-specs.json b/packages/yucca-sdk/orchestration-api/openapi-specs.json index ad27d211..6992516f 100644 --- a/packages/yucca-sdk/orchestration-api/openapi-specs.json +++ b/packages/yucca-sdk/orchestration-api/openapi-specs.json @@ -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": [ diff --git a/packages/yucca-sdk/orchestration-api/package.json b/packages/yucca-sdk/orchestration-api/package.json index 36a464f7..92869335 100644 --- a/packages/yucca-sdk/orchestration-api/package.json +++ b/packages/yucca-sdk/orchestration-api/package.json @@ -1,6 +1,6 @@ { "name": "orchestration-api", - "version": "0.1.63", + "version": "0.1.69", "description": "", "main": "index.js", "scripts": { diff --git a/packages/yucca-sdk/orchestration-api/src/controllers/runHistory.controller.ts b/packages/yucca-sdk/orchestration-api/src/controllers/runHistory.controller.ts index bf5deec1..88edaee4 100644 --- a/packages/yucca-sdk/orchestration-api/src/controllers/runHistory.controller.ts +++ b/packages/yucca-sdk/orchestration-api/src/controllers/runHistory.controller.ts @@ -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 { + return { run: await this.repository.get(id) }; + } + + @Sse('/:id/stream') @ApiParam({ name: 'id', type: String }) logStreamSse(@Param('id') id: string): Observable { return this.repository.getObservable(id); diff --git a/packages/yucca-sdk/orchestration-api/src/dto/repository.dto.ts b/packages/yucca-sdk/orchestration-api/src/dto/repository.dto.ts index d58e4bab..ea9f9488 100644 --- a/packages/yucca-sdk/orchestration-api/src/dto/repository.dto.ts +++ b/packages/yucca-sdk/orchestration-api/src/dto/repository.dto.ts @@ -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 { diff --git a/packages/yucca-sdk/orchestration-api/src/events/events.gateway.ts b/packages/yucca-sdk/orchestration-api/src/events/events.gateway.ts index 7d16124a..a3204370 100644 --- a/packages/yucca-sdk/orchestration-api/src/events/events.gateway.ts +++ b/packages/yucca-sdk/orchestration-api/src/events/events.gateway.ts @@ -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; + } | { type: 'DeviceFlowFailure'; }; diff --git a/packages/yucca-sdk/orchestration-api/src/repositories/restic.repository.ts b/packages/yucca-sdk/orchestration-api/src/repositories/restic.repository.ts index 05bff985..23f1a7ec 100644 --- a/packages/yucca-sdk/orchestration-api/src/repositories/restic.repository.ts +++ b/packages/yucca-sdk/orchestration-api/src/repositories/restic.repository.ts @@ -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); diff --git a/packages/yucca-sdk/orchestration-api/src/repositories/runHistory.repository.ts b/packages/yucca-sdk/orchestration-api/src/repositories/runHistory.repository.ts index 7fa034f4..49682f84 100644 --- a/packages/yucca-sdk/orchestration-api/src/repositories/runHistory.repository.ts +++ b/packages/yucca-sdk/orchestration-api/src/repositories/runHistory.repository.ts @@ -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, 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, 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(); - createLogAsync(repositoryId: string, fn: (log: WriteStream) => Promise) { + createLogAsync(repositoryId: string, type: RunType, fn: (log: WriteStream) => Promise) { return new Promise( (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) { diff --git a/packages/yucca-sdk/orchestration-api/src/schema/migrations/20260507120000-AddRunHistoryType.ts b/packages/yucca-sdk/orchestration-api/src/schema/migrations/20260507120000-AddRunHistoryType.ts new file mode 100644 index 00000000..0ed79f6f --- /dev/null +++ b/packages/yucca-sdk/orchestration-api/src/schema/migrations/20260507120000-AddRunHistoryType.ts @@ -0,0 +1,17 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + 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): Promise { + await db.schema.alterTable('runHistory').dropColumn('type').execute(); +} diff --git a/packages/yucca-sdk/orchestration-api/src/schema/tables/runHistory.table.ts b/packages/yucca-sdk/orchestration-api/src/schema/tables/runHistory.table.ts index 906c5d8d..6cab5367 100644 --- a/packages/yucca-sdk/orchestration-api/src/schema/tables/runHistory.table.ts +++ b/packages/yucca-sdk/orchestration-api/src/schema/tables/runHistory.table.ts @@ -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; } diff --git a/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts b/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts index d5b8ac2c..9ba1ae6f 100644 --- a/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts +++ b/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts @@ -68,6 +68,6 @@ export class AuthService { }; } - throw new InternalServerErrorException('No events received'); + throw new InternalServerErrorException('Failed to start authentication with FUTO Backups'); } } diff --git a/packages/yucca-sdk/orchestration-api/src/services/integrations.service.ts b/packages/yucca-sdk/orchestration-api/src/services/integrations.service.ts index 211520b4..720d5e75 100644 --- a/packages/yucca-sdk/orchestration-api/src/services/integrations.service.ts +++ b/packages/yucca-sdk/orchestration-api/src/services/integrations.service.ts @@ -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 }, diff --git a/packages/yucca-sdk/orchestration-api/src/services/repository.service.ts b/packages/yucca-sdk/orchestration-api/src/services/repository.service.ts index ca4d33f5..9c2e6270 100644 --- a/packages/yucca-sdk/orchestration-api/src/services/repository.service.ts +++ b/packages/yucca-sdk/orchestration-api/src/services/repository.service.ts @@ -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, diff --git a/packages/yucca-sdk/orchestration-api/src/services/schedule.service.ts b/packages/yucca-sdk/orchestration-api/src/services/schedule.service.ts index 57d9eaff..2b167ab5 100644 --- a/packages/yucca-sdk/orchestration-api/src/services/schedule.service.ts +++ b/packages/yucca-sdk/orchestration-api/src/services/schedule.service.ts @@ -163,15 +163,19 @@ export class ScheduleService { }; } - async updateSchedule( - scheduleId: string, - { name, paused, cron, repositories }: ScheduleUpdateRequestDto, - ): Promise { + async updateSchedule(scheduleId: string, dto: ScheduleUpdateRequestDto): Promise { 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 { const linked = new Set(await this.schedule.getRepositoryIds(scheduleId)); const set: Updateable = { diff --git a/packages/yucca-sdk/orchestration-api/src/utils/restic.ts b/packages/yucca-sdk/orchestration-api/src/utils/restic.ts new file mode 100644 index 00000000..0c395042 --- /dev/null +++ b/packages/yucca-sdk/orchestration-api/src/utils/restic.ts @@ -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'); + }; +}; diff --git a/packages/yucca-sdk/orchestration-ui/package.json b/packages/yucca-sdk/orchestration-ui/package.json index 5d8f9ef6..9513d169 100644 --- a/packages/yucca-sdk/orchestration-ui/package.json +++ b/packages/yucca-sdk/orchestration-ui/package.json @@ -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", diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/BackendItem.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/BackendItem.svelte new file mode 100644 index 00000000..2c72f45d --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/BackendItem.svelte @@ -0,0 +1,64 @@ + + + + {#snippet icon()} + + {/snippet} + + {BackendNames[backend.type]} + + {#if repositoryBackend?.primary} + (primary) + {/if} + + {#snippet trailing()} + {#if repositoryBackend} + {backend.isOnline + ? repositoryBackend.online + ? "Active" + : "Missing on service" + : "Offline"} + {:else} + {backend.isOnline ? "Online" : "Offline"} + {/if} + {/snippet} + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/BackendsList.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/BackendsList.svelte index b4035c41..d4743f2d 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/BackendsList.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/BackendsList.svelte @@ -1,98 +1,73 @@ -{#if query.isLoading} - -{:else if query.isError} - {getReadableErrorMessage(query.error)} -{:else if query.isSuccess} - {#if yuccaBackend} - - - FUTO Backups - - {#if yuccaBackend.isOnline} - - - - + + + {#snippet title()} + {#if repository} + Where your backup is stored {:else} - - - + Configured backends {/if} - - {:else} - - - FUTO Backups - Upsell text here - - - - - - {/if} + {/snippet} - {#each query.data as backend (backend.id)} - {#if backend.type !== "yucca"} - - - {backend.type === "local" ? "Local Storage" : "S3 Server"} - - - - - - {/if} - {/each} + {#each query.data as backend (backend.id)} + backend.id === id, + )} + /> + {/each} + {#if $advanced} + handleSetupLocalStorage()}>New local storage - {/if} -{/if} + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/CreateLocalBackend.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/CreateLocalBackend.svelte index cb03e505..be1e88d5 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/CreateLocalBackend.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/CreateLocalBackend.svelte @@ -1,30 +1,22 @@ - - -
- -
- -
-
+ + {#snippet title()}Path{/snippet} + {#snippet description()}Local directory to store backups.{/snippet} +
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/OAuthDeviceFlow.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/OAuthDeviceFlow.svelte index 8202d0bf..7ca9171e 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/OAuthDeviceFlow.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backends/OAuthDeviceFlow.svelte @@ -1,14 +1,27 @@ - - - - - - - - - - - {#if initialConfiguration} - - - - {/if} - - {#if query.isLoading} - - {:else if query.isError} - {getReadableErrorMessage(query.error)} - {:else if query.isSuccess && query.data.immichState} - {@const immich = query.data.immichState} - - -
- Data Folders - Select which Immich data to include in backups -
- {#each immich.dataFolders as folder} - - {/each} -
- - -
- Backup Configuration - Include your backup settings & schedules -
- -
- - -
- External Libraries - Choose which external libraries to back up -
- - - {#if !allLibraries} - {#each immich.libraries as library} - - {/each} - {#if immich.libraries.length === 0} - No external libraries found. - {/if} - {/if} -
- {/if} -
-
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ConfigureRepositoryModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ConfigureRepositoryModal.svelte index 08187873..bb8f6a53 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ConfigureRepositoryModal.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ConfigureRepositoryModal.svelte @@ -1,22 +1,20 @@ {#if repository.configuration} - - Backup Paths - {#each paths as path (path)} - - {path} - paths.delete(path)} - /> - - {/each} - - {#if paths.size === 0} - No paths configured yet. - {/if} - -
- -
-
+ + {#snippet label()}Backup Paths{/snippet} + {#snippet empty()}No paths configured yet.{/snippet} + {/if} + + {#if advanced} + + {/if}
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/CreateRepositoryModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/CreateRepositoryModal.svelte index 93a15b50..5cfca990 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/CreateRepositoryModal.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/CreateRepositoryModal.svelte @@ -1,4 +1,5 @@ - + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/FileBrowserModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/FileBrowserModal.svelte deleted file mode 100644 index c1505534..00000000 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/FileBrowserModal.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - -{#if listing} - - - - - open(listing!.parent)} - /> - .. - - - {#each listing.items as item (item.path)} - {#if item.isDirectory || !folders} - - {#if item.isDirectory} - open(item.path)} - /> - {:else} -
- {/if} - {item.path.split(/\\|\//).pop()} - { - onSelect(item.path); - onClose(); - }} - /> -
- {/if} - {/each} -
-
-
-{:else} - - - - - -{/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ImportRepositoryModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ImportRepositoryModal.svelte index 4d519d27..3b4ecdd4 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ImportRepositoryModal.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ImportRepositoryModal.svelte @@ -1,12 +1,12 @@ - - - - Selected Files - {#each include as path (path)} - - {path} - include.delete(path)} - /> - - {/each} - - {#if include.size === 0} - Restoring all files and folders. - {/if} - -
- -
-
- - + + + handleGetSnapshotListing(repository, snapshot, path)} > - - + {#snippet label()}Files to restore{/snippet} + {#snippet empty()}Restoring all files and folders.{/snippet} + - {#if !inPlace} - - - - - modalManager.open(FileBrowserModal, { - folders: true, - onSelect: (value) => (target = value), - })} - /> - - - {/if} + + Options + + + + In-place restore + + Restore files to where they were originally. + + + + + + {#if !inPlace} + + {#snippet title()}Target{/snippet} + {#snippet description()} + Where do you want this backup restored to? + {/snippet} + + {/if} +
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/RunHistoryModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/RunHistoryModal.svelte deleted file mode 100644 index 9eecb4cc..00000000 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/RunHistoryModal.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - - - - {#if query.isLoading} - - {:else if query.isError} - {getReadableErrorMessage(query.error)} - {:else if query.isSuccess} - - - Start - End - Status - - - - - {#each query.data as run (run.id)} - - {run.start} - {run.end} - {run.status} - - - - - {/each} - -
- {/if} -
-
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/SnapshotsListModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/SnapshotsListModal.svelte deleted file mode 100644 index a3255a5b..00000000 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/SnapshotsListModal.svelte +++ /dev/null @@ -1,103 +0,0 @@ - - - - - {#if query.isLoading} - - {:else if query.isError} - {getReadableErrorMessage(query.error)} - {:else if query.isSuccess} - - - Snapshot - Created - - - - - {#each query.data as snapshot (snapshot.id)} - - {snapshot.id.slice(0, 12)} - {DateTime.fromISO(snapshot.time).toRelative()} - - restoreSnapshot(snapshot.id)} - /> - deleteSnapshot(snapshot.id)} - /> - - - {/each} - -
- {/if} -
-
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ViewLogModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ViewLogModal.svelte index b0436eed..18eea653 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ViewLogModal.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/dialogs/ViewLogModal.svelte @@ -1,6 +1,13 @@ - + + + - - {#each log.errors as error} - {error} - {/each} + + + {#if !run} + Connecting… + {:else if run.status === "incomplete"} + + {isRestore ? "Restoring" : "Backing up"} · {Math.round( + log.status.progress * 100, + )}% + + {:else if run.status === "failed"} + + {isRestore ? "Restore" : "Backup"} failed after {duration} + + {:else if log.errors.length > 0} + + {isRestore ? "Restored" : "Backed up"} in {duration} · {log.errors + .length} error{log.errors.length === 1 ? "" : "s"} + + {:else} + + {isRestore ? "Restored" : "Backed up"} in {duration} + + {/if} - - 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} + + Started + + {/if} + + + {#if running} + + {#if log.status.text} + 0.5 ? "text-light" : "text-dark"} + > + {log.status.text} + {/if} - - + + {/if} - {#if log.status.currentFiles.length > 0} + {#if log.errors.length > 0} + + {log.errors[0]} + {#if log.errors.length > 1} + + + {#each log.errors.slice(1) as error} + {error} + {/each} + + + {/if} + + {/if} + + {#if log.summary} - {#each log.status.currentFiles as file} - {file} + {#if isRestore} + + {(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} + + {#if log.summary.bytes_restored !== undefined} + + Restored + {#if log.summary.total_bytes !== undefined} + of + + {/if} + + {/if} + {:else} + + {(log.summary.files_new ?? 0).toLocaleString()} new · + {(log.summary.files_changed ?? 0).toLocaleString()} changed · + {(log.summary.files_unmodified ?? 0).toLocaleString()} unchanged + + {#if log.summary.data_added !== undefined} + + Added + {#if log.summary.total_bytes_processed !== undefined} + of + processed + {/if} + + {/if} + {/if} + + {/if} + + {#if running} + + {#each log.status.currentFiles.slice(0, 3) as file} + + {file} + {/each} {/if} {#if $advanced} - Event Log - - - {#each log.events as event} - {JSON.stringify(event)} - {/each} - - + + Event Log + + + {#each log.events as event} + + {JSON.stringify(event)} + + {/each} + + + {/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RepositoryRunHistory.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RepositoryRunHistory.svelte new file mode 100644 index 00000000..784c3058 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RepositoryRunHistory.svelte @@ -0,0 +1,39 @@ + + + + + + {#snippet title()} + Recent backup attempts + {/snippet} + + {#snippet children(runs)} + {#if runs.length === 0} + No recent backups + {:else} + {#each runs.slice(0, 10) as run (run.id)} + + {/each} + {/if} + {/snippet} + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RepositoryRunHistoryItem.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RepositoryRunHistoryItem.svelte new file mode 100644 index 00000000..4a04a43a --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RepositoryRunHistoryItem.svelte @@ -0,0 +1,42 @@ + + + + {#snippet icon()} + {#if run.status === "complete"} + + {:else if run.status === "failed"} + + {:else} + + {/if} + {/snippet} + + {#if run.status === "incomplete"} + {run.type === "restore" ? "Restoring" : "Backing up"} · started + + {:else if run.status === "failed"} + {run.type === "restore" ? "Restore" : "Backup"} failed + {#if run.end}{/if} + {:else} + {run.type === "restore" ? "Restored" : "Backed up"} + {#if run.end}{/if} + {/if} + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RunHistoryModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RunHistoryModal.svelte new file mode 100644 index 00000000..4c9c15a6 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/run-history/RunHistoryModal.svelte @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/RepositorySnapshotsList.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/RepositorySnapshotsList.svelte new file mode 100644 index 00000000..d2858fce --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/RepositorySnapshotsList.svelte @@ -0,0 +1,39 @@ + + + + + + {#snippet title()} + Snapshots + {/snippet} + + {#snippet children(snapshots)} + {#if snapshots.length === 0} + No backups yet + {:else} + {#each snapshots.slice(0, 10) as snapshot (snapshot.id)} + + {/each} + {/if} + {/snippet} + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/RepositorySnapshotsListItem.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/RepositorySnapshotsListItem.svelte new file mode 100644 index 00000000..8d955bdd --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/RepositorySnapshotsListItem.svelte @@ -0,0 +1,41 @@ + + + + + + + {#if snapshot.summary} + + · {snapshot.summary.totalFiles.toLocaleString()} files · + + + + {#if snapshot.summary.filesNew > 0 || snapshot.summary.filesChanged > 0} + + {#if snapshot.summary.filesNew > 0} + · {snapshot.summary.filesNew.toLocaleString()} new + {/if} + {#if snapshot.summary.filesChanged > 0} + · {snapshot.summary.filesChanged.toLocaleString()} changed + {/if} + + {/if} + {/if} + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/SnapshotsListModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/SnapshotsListModal.svelte new file mode 100644 index 00000000..56367d2d --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/backups/snapshots-list/SnapshotsListModal.svelte @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/Dashboard.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/Dashboard.svelte index ca12e53c..67bd3b73 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/Dashboard.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/Dashboard.svelte @@ -1,39 +1,27 @@ - + {#if query.isLoading} @@ -84,21 +63,6 @@ {:else if query.isSuccess} - {#if integrationsQuery.isSuccess && integrationsQuery.data.immichIntegration} - - 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} - - {/if} - @@ -109,119 +73,4 @@ repositories={query.data} onNavigate={onNavigate ? () => onNavigate("backups") : undefined} /> - - {#if $advanced} - Mock data provided for advanced graphs below. - - - - - - Storage Breakdown - 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, - }, - ]} - /> - - - - - - - Transfer Bandwidth - 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 }, - ]} - /> - - - - {@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)), - })), - )} - - - - Job Activity - - - - - - {/if} {/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/ImmichIntegrationCard.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/ImmichIntegrationCard.svelte deleted file mode 100644 index 931519e2..00000000 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/ImmichIntegrationCard.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - - - -
- Immich - -
- Immich Backup - {#if unconfigured} - - Set up Immich automatic backups - - {:else if metrics?.lastBackup && (!metrics.lastSuccessfulBackup || +new Date(metrics.lastBackup) > +new Date(metrics.lastSuccessfulBackup))} - - Failed - - {:else if metrics?.lastSuccessfulBackup} - - Successful - - {:else} - Never run - {/if} -
- - - {#if unconfigured} - - {:else} - - {#if schedule} - - {/if} - {/if} - -
-
-
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/visualisations/HeatMap.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/visualisations/HeatMap.svelte deleted file mode 100644 index 8da3e644..00000000 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/visualisations/HeatMap.svelte +++ /dev/null @@ -1,173 +0,0 @@ - - -
- - {#each rows as row} - {#each cols as col} - {@const value = valueMap.get(`${row}:${col}`) ?? 0} - {@const isHovered = hovered?.row === row && hovered?.col === col} - - onCellEnter(row, col, value, e)} - onmousemove={onCellMove} - onmouseleave={onCellLeave} - /> - {/each} - {/each} - - - {#each rows as row} - - {row} - - {/each} - - - - {#each cols as col, i} - {#if i % labelInterval === 0} - - {col} - - {/if} - {/each} - - - - {#if tooltip} -
-
{tooltip.row} {tooltip.col}
-
{tooltip.value} {tooltip.value === 1 ? "job" : "jobs"}
-
- {/if} -
- -
- Less - {#each [0, 25, 50, 75, 100] as pct} - - {/each} - More -
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/visualisations/StackedBarChart.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/visualisations/StackedBarChart.svelte deleted file mode 100644 index 0a802b19..00000000 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/dashboard/visualisations/StackedBarChart.svelte +++ /dev/null @@ -1,200 +0,0 @@ - - -
- - {#each series as layer} - - {#each layer as d} - {@const cat = String(d.data[categoryKey])} - - onBarEnter(cat, e)} - onmousemove={onBarMove} - onmouseleave={onBarLeave} - /> - {/each} - - {/each} - - - - - {#each categories as cat} - - {cat} - - {/each} - - - - {#each ticks as tick} - {#if tick !== 0} - - {/if} - - - {fmt(tick)} - - {/each} - - - - {#if tooltip} -
-
{tooltip.cat}
- {#each tooltip.values as v} -
- - {v.key}: {fmt(v.value)} -
- {/each} -
- {/if} -
- -
- {#each keys as key} -
- - {key} -
- {/each} -
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichBackupsPage.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichBackupsPage.svelte new file mode 100644 index 00000000..8eda56dd --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichBackupsPage.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichConfigureBackup.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichConfigureBackup.svelte new file mode 100644 index 00000000..e6d500dd --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichConfigureBackup.svelte @@ -0,0 +1,339 @@ + + + { + onCancel?.(); + onClose?.(); + }} + {onSubmit} +> + + + + + + + {#snippet children({ immichState: immich })} + + What to back up + + {#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)} + + {/each} + + + + + + + External Libraries + + External libraries stored outside of Immich can also be included + in your backup.
By default, all existing and future external + libraries will be included in your backup. +
+
+ + + {#each [{ id: "all" as const, label: "All" }, { id: "none" as const, label: "None" }, { id: "some" as const, label: "Pick" }] as option (option.id)} + + {/each} + + + {#if librariesMode === "all"} + {#if immich!.libraries.length !== 0} + Libraries included: {immich!.libraries + .map((item) => item.name) + .join(", ")} + {/if} + {:else if librariesMode === "some"} + {#if immich!.libraries.length === 0} + No external libraries found. + {:else} + + {#each immich!.libraries as library (library.id)} + + {/each} + + {/if} + {/if} +
+ + + Schedule + + + {#each [{ id: "daily" as const, label: "Every day" }, { id: "custom" as const, label: "Custom cron expression" }] as option (option.id)} + + {/each} + + + {#if scheduleMode === "daily"} + + { + const [h, m] = ( + e.currentTarget as HTMLInputElement + ).value.split(":"); + scheduleHour = Number(h); + scheduleMinute = Number(m); + }} + /> + + {:else} + + + + + Your backups will run {cronstrue.toString(cron, { + verbose: true, + })} + {/if} + + {/snippet} +
+ + + Advanced + + + + +
+
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichConfirmDefaultBackup.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichConfirmDefaultBackup.svelte new file mode 100644 index 00000000..3666330d --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichConfirmDefaultBackup.svelte @@ -0,0 +1,80 @@ + + + + + + + We'll create a backup with sensible defaults. You can change anything + later from the Immich backups page. + + + + + + Photos, videos, database, and configuration + + + + All external libraries + + + + Runs every day at 3:00 AM + + + + + + + + + + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichManageBackup.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichManageBackup.svelte new file mode 100644 index 00000000..86187e2b --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichManageBackup.svelte @@ -0,0 +1,77 @@ + + + + + + + {#if repository && schedule} + + + + + + + {/if} + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichManageBackupOverview.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichManageBackupOverview.svelte new file mode 100644 index 00000000..3a1eb855 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichManageBackupOverview.svelte @@ -0,0 +1,100 @@ + + + + + {#snippet icon()} + + {/snippet} + + + + Your library + + · + {cronstrue.toString(schedule.cron, { + verbose: true, + })} + + + + + {#snippet trailing()} + + {/snippet} + + + {#if repository.metrics.lastBackup} + {#if repository.metrics.lastBackup !== repository.metrics.lastSuccessfulBackup} + + + Last backup failed + + + {:else} + + + Last backup successful + + + {/if} + {:else} + + + Backup is yet to run. + + + {/if} + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichOnboardingRestoreFlow.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichOnboardingRestoreFlow.svelte new file mode 100644 index 00000000..166af70f --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichOnboardingRestoreFlow.svelte @@ -0,0 +1,75 @@ + + +{#if typeof status === "object"} + {#if stage === "key-import"} + + {:else if stage === "backup-service"} + (stage = "restore-point")} + onCancel={onExit} + restore + /> + {:else if stage === "key-reimport"} + (stage = "restore-point")} + onCancel={() => (stage = "restore-point")} + /> + {:else if stage === "restore-point"} + (stage = "key-reimport")} + onCancel={onExit} + {onFinish} + /> + {/if} +{:else} + +{/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichOnboardingSetupFlow.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichOnboardingSetupFlow.svelte new file mode 100644 index 00000000..f9f21164 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/integrations/immich/ImmichOnboardingSetupFlow.svelte @@ -0,0 +1,106 @@ + + +{#if typeof status === "object"} + {#if stage === "finished"} + {@render children()} + {:else if stage === "welcome"} + (stage = "key-intro")} + onImportKey={() => (stage = "key-import")} + onCancel={onExit} + /> + {:else if stage === "key-intro"} + (stage = "key-save")} + onCancel={onExit} + /> + {:else if stage === "key-save"} + (stage = "key-confirm")} + onCancel={onExit} + /> + {:else if stage === "key-confirm"} + (stage = "key-save")} + onCancel={onExit} + {onConfirmKey} + /> + {:else if stage === "backup-service"} + + {:else if stage === "backup-confirm"} + (stage = "backup-create")} + onConfirm={() => (stage = "finished")} + onCancel={onExit} + /> + {:else if stage === "backup-create"} + (stage = "finished")} + onCancel={onExit} + {backendId} + /> + {/if} +{:else} + +{/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/Onboarding.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/Onboarding.svelte deleted file mode 100644 index 8947e89e..00000000 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/Onboarding.svelte +++ /dev/null @@ -1,141 +0,0 @@ - - -{#if flow === "immich-restore"} - - {#if stage === 0} - { - code = key; - onConfirmKey(); - }} - {onCancel} - /> - {:else if stage === 1} - (stage = 2)} {onCancel} restore /> - {:else if stage === 10} - (stage = 2)} onCancel={() => (stage = 2)} /> - {:else} - (stage = 10)} {onCancel} {onFinish} /> - {/if} -{:else if flow === "immich-setup"} - - {#if stage === 0} - (stage = 5)} {onCancel} /> - {:else if stage === 1} - - {:else if stage === 2} - - {:else if stage === 3} - - {:else if stage === 4} - (stage = 6)} {onCancel} /> - {:else if stage === 5} - (stage = 0)} - onImported={(key) => { - code = key; - stage = 2; - }} - {onCancel} - /> - {:else} - - {/if} -{:else} - - {#if stage === 0} - (stage = 5)} {onCancel} /> - {:else if stage === 1} - - {:else if stage === 2} - - {:else if stage === 3} - - {:else if stage === 4} - (stage = 6)} {onCancel} /> - {:else if stage === 5} - (stage = 0)} - onImported={(key) => { - code = key; - stage = 2; - }} - {onCancel} - /> - {:else if stage === 6} - - {:else if stage === 7} - - {/if} -{/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/OnboardingGate.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/OnboardingGate.svelte index f1a4fd4b..928c9936 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/OnboardingGate.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/OnboardingGate.svelte @@ -1,18 +1,17 @@ + +{#if stage === 0} + (stage = 5)} {onCancel} /> +{:else if stage === 1} + +{:else if stage === 2} + +{:else if stage === 3} + +{:else if stage === 4} + (stage = 6)} {onCancel} /> +{:else if stage === 5} + (stage = 0)} + onImported={(key) => { + code = key; + stage = 2; + }} + {onCancel} + /> +{:else if stage === 6} + +{:else if stage === 7} + +{/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/restore-point-flow/RestorePointFlow.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/restore-point-flow/RestorePointFlow.svelte index cdb45646..73514dab 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/restore-point-flow/RestorePointFlow.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/restore-point-flow/RestorePointFlow.svelte @@ -1,20 +1,14 @@ - - - - - - - {#if restore} - FUTO Backups - {:else} - $0/TB per month - {/if} - Backups powered by FUTO - Alerts and monitoring included - - - - - modalManager.open(CreateLocalBackend, { - onCreated: onNext, - })} - > - - Local Folder - Select a local destination - - - - - - - - - - diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/KeyIntro.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/KeyIntro.svelte deleted file mode 100644 index cbb7b97a..00000000 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/KeyIntro.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - 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. - - If you lose it, you won't be able to access your photo backup! - - - - - - - - - diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageBackupServices.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageBackupServices.svelte new file mode 100644 index 00000000..b2fc5b52 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageBackupServices.svelte @@ -0,0 +1,82 @@ + + + + + + + {#snippet icon()} + + {/snippet} + + + FUTO Backups + Simple, hosted backups. + + + {#snippet trailing()} + + {/snippet} + + + {#snippet icon()} + + {/snippet} + + + Local Storage + A folder on this computer. + + + {#snippet trailing()} + + {/snippet} + + + + + + + + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/ConfirmKey.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeyConfirm.svelte similarity index 76% rename from packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/ConfirmKey.svelte rename to packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeyConfirm.svelte index 2c012dc2..22ed3d93 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/ConfirmKey.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeyConfirm.svelte @@ -7,7 +7,8 @@ Modal, ModalBody, ModalFooter, - VStack, + Stack, + Text, } from "@immich/ui"; type Props = { @@ -27,13 +28,22 @@ }; - + - + + Ensure you've saved the key properly. + - +
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/ImportKey.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeyImport.svelte similarity index 100% rename from packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/ImportKey.svelte rename to packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeyImport.svelte diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeyIntro.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeyIntro.svelte new file mode 100644 index 00000000..c25715a0 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeyIntro.svelte @@ -0,0 +1,50 @@ + + + + + + + + You are about to be given a code which you need to put in a safe + place. + + + + If you lose the key, your backup is gone. + + + + + + + + + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/SaveKey.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeySave.svelte similarity index 69% rename from packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/SaveKey.svelte rename to packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeySave.svelte index 9c7930bd..027afa6a 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/SaveKey.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageKeySave.svelte @@ -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 @@ }; - + - - + + Save this key somewhere safe, it will be used to restore your backups. + + {#if code}
{code}
@@ -88,15 +95,29 @@
- - - - - - {#if PasswordCredential} - - {/if} -
+ + + + + + + {#if PasswordCredential} + + {/if} + +
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/Welcome.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageWelcome.svelte similarity index 85% rename from packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/Welcome.svelte rename to packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageWelcome.svelte index 9e4ec754..ef6c3c2b 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/Welcome.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/OnboardingStageWelcome.svelte @@ -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(); - + - + @@ -43,7 +44,7 @@ storage. - + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/CreateFirstBackup.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/SampleCreateFirstBackup.svelte similarity index 100% rename from packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/CreateFirstBackup.svelte rename to packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/SampleCreateFirstBackup.svelte diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/CreateFirstSchedule.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/SampleCreateFirstSchedule.svelte similarity index 100% rename from packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/CreateFirstSchedule.svelte rename to packages/yucca-sdk/orchestration-ui/src/lib/components/onboarding/stages/SampleCreateFirstSchedule.svelte diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/scraps/ImmichTestUi.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/scraps/ImmichTestUi.svelte new file mode 100644 index 00000000..946b2c04 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/scraps/ImmichTestUi.svelte @@ -0,0 +1,183 @@ + + + + +
+
+ {}} + class="sidebar:hidden" + /> + +
+
+ + +
+ + + + {}} + title="Upload" + aria-label="Upload" + icon={mdiTrayArrowUp} + class="lg:hidden" + /> + + + + {}} + aria-label="Notifications" + /> + + {}} + aria-label="Cast" + /> + + +
+
+
+
+ + +
+ + + + + + + + + + + + + + +
+
+ + {#if $testUiRestore} + testUiRestore.set(false)} + /> + {:else} + + + + {/if} +
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/scraps/TestUi.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/scraps/TestUi.svelte index fb8aea0f..d410ae1b 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/components/scraps/TestUi.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/scraps/TestUi.svelte @@ -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 @@ - App Name Here +
+ + {#if mock} + FUTO Backups + {:else} + App Name Here + {/if} + + +
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PageLayout.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PageLayout.svelte new file mode 100644 index 00000000..0282bc7b --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PageLayout.svelte @@ -0,0 +1,67 @@ + + +
+ {#if title || actions.length > 0} +
+
+ {#if title} +
{title}
+ {/if} +
+ + {#if actions.length > 0} + + + + {/if} +
+ {/if} + +
+ {@render children?.()} +
+
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathListField.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathListField.svelte new file mode 100644 index 00000000..1093ab2a --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathListField.svelte @@ -0,0 +1,83 @@ + + + + {#snippet title()} + {@render label?.()} + {/snippet} + + {#if paths.size > 0} + {#each [...paths] as path (path)} + + {path} + paths.delete(path)} + /> + + {/each} + {:else if empty} + + {@render empty()} + + {/if} + + + + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathPickerField.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathPickerField.svelte new file mode 100644 index 00000000..bbedbb4f --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathPickerField.svelte @@ -0,0 +1,74 @@ + + + + {#if title || description} + + {#if title} + {@render title()} + {/if} + {#if description} + {@render description()} + {/if} + + {/if} + + + + + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathPickerModal.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathPickerModal.svelte new file mode 100644 index 00000000..77acb8dd --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/PathPickerModal.svelte @@ -0,0 +1,204 @@ + + + + + {#if description} + {description} + {/if} + + {#if !single} + {#if selected.size > 0} + + {#snippet title()} + Selected paths + {/snippet} + {#each [...selected] as path (path)} + + {path} + selected.delete(path)} + /> + + {/each} + + {:else} + + Selected paths + No paths selected + + {/if} + {/if} + + {#if !listing} + + Browse +
+ +
+
+ {:else} + + {#snippet title()} + Browse + {/snippet} + + + + + {listing.path} + + {#if listing.parent && listing.parent !== listing.path} + browse(listing!.parent)} + /> + {/if} + + + {#each sortedItems as item (item.path)} + {#if item.isDirectory || !foldersOnly} + + {#if item.isDirectory} + browse(item.path)} + class="flex-1 justify-start" + > + {item.path.split(/[\\/]/).pop()} + + {:else} + + + + {item.path.split(/[\\/]/).pop()} + + + {/if} + add(item.path)} + /> + + {/if} + {/each} + + {#if sortedItems.filter((item) => item.isDirectory || !foldersOnly).length === 0} + {#if foldersOnly} + + + + {:else} + + This folder is empty + + {/if} + {/if} + + {/if} +
+
diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/StackList.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/StackList.svelte new file mode 100644 index 00000000..08d98a33 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/StackList.svelte @@ -0,0 +1,30 @@ + + + + {#if title} + {@render title()} + {/if} + + {#if query?.isLoading} + + {:else if query?.isError} + {getReadableErrorMessage(query.error)} + {:else} + + {@render children(query?.data as T)} + + {/if} + diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/StackListItem.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/StackListItem.svelte new file mode 100644 index 00000000..8e238b73 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/ui/StackListItem.svelte @@ -0,0 +1,64 @@ + + +{#snippet body()} + + {#if icon} +
+ {@render icon?.()} +
+ {/if} + + + {@render children()} + + + {@render trailing?.()} + + {#if hasActions(actions)} + + {/if} +
+{/snippet} + +{#if onclick} + +{:else} + {@render body()} +{/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/components/util/Suspense.svelte b/packages/yucca-sdk/orchestration-ui/src/lib/components/util/Suspense.svelte new file mode 100644 index 00000000..4761b153 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/components/util/Suspense.svelte @@ -0,0 +1,21 @@ + + +{#if query.isLoading} + +{:else if query.isError} + {getReadableErrorMessage(query.error)} +{:else if query.isSuccess} + {@render children(query.data)} +{/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/fetch-client.ts b/packages/yucca-sdk/orchestration-ui/src/lib/fetch-client.ts index 56237470..a2657520 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/fetch-client.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/fetch-client.ts @@ -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 })); } diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/index.ts b/packages/yucca-sdk/orchestration-ui/src/lib/index.ts index 29fa0618..cf6a59f9 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/index.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/index.ts @@ -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'; diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/options.ts b/packages/yucca-sdk/orchestration-ui/src/lib/options.ts index 4b21a693..2cec1300 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/options.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/options.ts @@ -2,4 +2,6 @@ import { writable } from 'svelte/store'; export const options = { advanced: writable(false), + testUiRestore: writable(false), + demoPadding: writable(false), }; diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/services/backend.service.ts b/packages/yucca-sdk/orchestration-ui/src/lib/services/backend.service.ts index 5139d157..bd70b626 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/services/backend.service.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/services/backend.service.ts @@ -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 }; +}; diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/services/immich.integration.service.ts b/packages/yucca-sdk/orchestration-ui/src/lib/services/immich.integration.service.ts new file mode 100644 index 00000000..1c4f5416 --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/services/immich.integration.service.ts @@ -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 }; +}; diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/services/integrations.service.ts b/packages/yucca-sdk/orchestration-ui/src/lib/services/integrations.service.ts index 42668760..4f61c653 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/services/integrations.service.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/services/integrations.service.ts @@ -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, + ); diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/services/log.service.svelte.ts b/packages/yucca-sdk/orchestration-ui/src/lib/services/log.service.svelte.ts index da3571a3..768c5619 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/services/log.service.svelte.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/services/log.service.svelte.ts @@ -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(); diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/services/repository.service.ts b/packages/yucca-sdk/orchestration-ui/src/lib/services/repository.service.ts index 108ce5b8..4f43c535 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/services/repository.service.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/services/repository.service.ts @@ -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; diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/services/runHistory.service.ts b/packages/yucca-sdk/orchestration-ui/src/lib/services/runHistory.service.ts index 9fcfb7ea..e75c0245 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/services/runHistory.service.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/services/runHistory.service.ts @@ -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; + }>, + ) { + 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 }; +}; diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/services/snapshot.service.ts b/packages/yucca-sdk/orchestration-ui/src/lib/services/snapshot.service.ts index 871037e1..b2c28508 100644 --- a/packages/yucca-sdk/orchestration-ui/src/lib/services/snapshot.service.ts +++ b/packages/yucca-sdk/orchestration-ui/src/lib/services/snapshot.service.ts @@ -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; + }>, + ) { + 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 }; +}; diff --git a/packages/yucca-sdk/orchestration-ui/src/lib/utils/actions.ts b/packages/yucca-sdk/orchestration-ui/src/lib/utils/actions.ts new file mode 100644 index 00000000..57f102fa --- /dev/null +++ b/packages/yucca-sdk/orchestration-ui/src/lib/utils/actions.ts @@ -0,0 +1,5 @@ +import type { ActionItem } from '@immich/ui'; + +export function hasActions(actions: ActionItem[]) { + return actions.some((action) => !action.$if || action.$if?.()); +} diff --git a/packages/yucca-sdk/orchestration-ui/src/routes/+layout.svelte b/packages/yucca-sdk/orchestration-ui/src/routes/+layout.svelte index 65c4c643..9e36c96d 100644 --- a/packages/yucca-sdk/orchestration-ui/src/routes/+layout.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/routes/+layout.svelte @@ -1,6 +1,6 @@ - - {@render children()} - +
+ + {@render children()} + +
diff --git a/packages/yucca-sdk/orchestration-ui/src/routes/+page.svelte b/packages/yucca-sdk/orchestration-ui/src/routes/+page.svelte index 1882ad42..a5f3992a 100644 --- a/packages/yucca-sdk/orchestration-ui/src/routes/+page.svelte +++ b/packages/yucca-sdk/orchestration-ui/src/routes/+page.svelte @@ -1,21 +1,30 @@ - -
+
Orchestrator Test UI - - - - - + + + + + + + - - -
- - {#if mock} - - {:else} - (mock = false)}> - - - {/if} + + + + +
+ +
+ +{#if mock === "immich"} + (mock = true)} /> +{:else if mock} + +{:else} + (mock = true)}> + + +{/if} diff --git a/packages/yucca-sdk/orchestration-ui/src/routes/layout.css b/packages/yucca-sdk/orchestration-ui/src/routes/layout.css index 67958a2f..9306b006 100644 --- a/packages/yucca-sdk/orchestration-ui/src/routes/layout.css +++ b/packages/yucca-sdk/orchestration-ui/src/routes/layout.css @@ -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); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 804fe91b..218c6895 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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