feat: immich integration UX (#87)

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