@futo-org/backups-orchestrator-ui 0.35.1 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/dist/components/backups/BackupItem.svelte +10 -12
  2. package/dist/components/backups/BackupStatus.svelte +38 -28
  3. package/dist/components/backups/BackupStatus.svelte.d.ts +1 -1
  4. package/dist/components/backups/dialogs/ConfigureRepositoryModal.svelte +3 -5
  5. package/dist/components/backups/dialogs/ViewStatusModal.svelte +16 -4
  6. package/dist/components/backups/run-history/RepositoryRunHistoryItem.svelte +7 -0
  7. package/dist/components/backups/run-history/RepositoryRunHistoryPage.svelte +4 -1
  8. package/dist/components/dashboard/DashboardBackupHealth.svelte +29 -9
  9. package/dist/components/dashboard/DashboardRecentBackups.svelte +6 -5
  10. package/dist/components/integrations/immich/ImmichBackupsSidebarItem.svelte +10 -7
  11. package/dist/components/integrations/immich/ImmichManageBackupOverview.svelte +24 -11
  12. package/dist/components/integrations/immich/ImmichOnboardingSetupFlow.svelte +16 -9
  13. package/dist/components/onboarding/OnboardingGate.svelte +3 -3
  14. package/dist/components/onboarding/SampleOnboarding.svelte +19 -40
  15. package/dist/components/test/dashboard/ActiveJobs.svelte +14 -1
  16. package/dist/components/test/dashboard/BackupHealth.svelte +5 -6
  17. package/dist/components/test/dashboard/RecentBackups.svelte +2 -1
  18. package/dist/components/ui/PathPickerModal.svelte +22 -5
  19. package/dist/fetch-client.d.ts +3 -3
  20. package/dist/services/log.service.svelte.js +6 -0
  21. package/dist/utils/backup-status.d.ts +3 -0
  22. package/dist/utils/backup-status.js +16 -0
  23. package/package.json +2 -2
  24. package/dist/components/onboarding/stages/SampleCreateFirstBackup.svelte +0 -43
  25. package/dist/components/onboarding/stages/SampleCreateFirstBackup.svelte.d.ts +0 -7
  26. package/dist/components/onboarding/stages/SampleCreateFirstSchedule.svelte +0 -49
  27. package/dist/components/onboarding/stages/SampleCreateFirstSchedule.svelte.d.ts +0 -7
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { LocalRepositoryDto } from "../../fetch-client";
3
3
  import { getRepositoryActions } from "../../services/repository.service";
4
+ import { getBackupOutcome } from "../../utils/backup-status";
4
5
  import { Badge, FormatBytes, Icon } from "@immich/ui";
5
6
  import { mdiArchiveOutline } from "@mdi/js";
6
7
  import StackListItem from "../ui/StackListItem.svelte";
@@ -18,12 +19,7 @@
18
19
  s3: "S3 Server",
19
20
  };
20
21
 
21
- const failed = $derived(
22
- repository.metrics.lastBackup &&
23
- (!repository.metrics.lastSuccessfulBackup ||
24
- +new Date(repository.metrics.lastBackup) >
25
- +new Date(repository.metrics.lastSuccessfulBackup)),
26
- );
22
+ const outcome = $derived(getBackupOutcome(repository.metrics));
27
23
 
28
24
  const { BackupNow, Snapshots, History, Configure, Import, MetricsHistory } =
29
25
  $derived(getRepositoryActions(repository));
@@ -31,7 +27,7 @@
31
27
 
32
28
  <StackListItem
33
29
  title={repository.name}
34
- color={failed ? "danger" : "primary"}
30
+ color={outcome === "failed" ? "danger" : "primary"}
35
31
  actions={[BackupNow, Snapshots, History, Configure, Import, MetricsHistory]}
36
32
  >
37
33
  {#snippet icon()}
@@ -57,15 +53,17 @@
57
53
  <Badge size="tiny" color="danger">Offline</Badge>
58
54
  {/if}
59
55
 
60
- {#if failed}
56
+ {#if outcome === "failed"}
61
57
  <Badge size="tiny" color="danger">
62
58
  Failed <RelativeTime time={repository.metrics.lastBackup!} />
63
59
  </Badge>
64
- {:else if repository.metrics.lastSuccessfulBackup}
60
+ {:else if outcome === "warn"}
61
+ <Badge size="tiny" color="warning">
62
+ Warnings <RelativeTime time={repository.metrics.lastBackup!} />
63
+ </Badge>
64
+ {:else if outcome === "complete"}
65
65
  <Badge size="tiny" color="success">
66
- Successful <RelativeTime
67
- time={repository.metrics.lastSuccessfulBackup}
68
- />
66
+ Successful <RelativeTime time={repository.metrics.lastBackup!} />
69
67
  </Badge>
70
68
  {:else}
71
69
  <Badge size="tiny" color="warning">Never backed up</Badge>
@@ -5,6 +5,7 @@
5
5
  | "connecting"
6
6
  | "running"
7
7
  | "complete"
8
+ | "warned"
8
9
  | "failed";
9
10
  </script>
10
11
 
@@ -50,8 +51,6 @@
50
51
  onRetry,
51
52
  }: Props = $props();
52
53
 
53
- let showErrors = $state(false);
54
-
55
54
  const phases: Record<BackupStatusType, [string, string, string]> = {
56
55
  backup: ["Preparing backup", "Backing up", "Finalizing backup"],
57
56
  restore: ["Preparing restore", "Restoring", "Finalizing restore"],
@@ -64,6 +63,12 @@
64
63
  forget: "Old backups were pruned successfully",
65
64
  };
66
65
 
66
+ const warned: Record<BackupStatusType, string> = {
67
+ backup: "Your library was backed up, with warnings",
68
+ restore: "Your library was restored, with warnings",
69
+ forget: "Old backups were pruned, with warnings",
70
+ };
71
+
67
72
  const failed: Record<BackupStatusType, string> = {
68
73
  backup: "Your library could not be backed up",
69
74
  restore: "Your library could not be restored",
@@ -104,6 +109,9 @@
104
109
  case "complete": {
105
110
  return succeeded[type];
106
111
  }
112
+ case "warned": {
113
+ return warned[type];
114
+ }
107
115
  default: {
108
116
  return `${phase} · ${Math.round(progress * 100)}%`;
109
117
  }
@@ -111,10 +119,20 @@
111
119
  });
112
120
 
113
121
  const titleColor = $derived(
114
- backupState === "complete" ? "success" : backupState === "failed" ? "danger" : "primary",
122
+ backupState === "complete"
123
+ ? "success"
124
+ : backupState === "warned"
125
+ ? "warning"
126
+ : backupState === "failed"
127
+ ? "danger"
128
+ : "primary",
115
129
  );
116
130
 
117
- const terminal = $derived(backupState === "complete" || backupState === "failed");
131
+ const terminal = $derived(
132
+ backupState === "complete" ||
133
+ backupState === "warned" ||
134
+ backupState === "failed",
135
+ );
118
136
  </script>
119
137
 
120
138
  <Stack gap={4}>
@@ -132,7 +150,7 @@
132
150
  <Text color="muted">Completed in {duration}</Text>
133
151
  {/if}
134
152
 
135
- {#if backupState === "complete" && errors.length > 0}
153
+ {#if backupState === "warned" && errors.length > 0}
136
154
  <Text color="warning">
137
155
  Completed with {errors.length}
138
156
  {errors.length === 1 ? "warning" : "warnings"}.
@@ -151,38 +169,30 @@
151
169
  {/if}
152
170
 
153
171
  {#if terminal && (errors.length > 0 || (backupState === "failed" && onRetry))}
154
- <HStack gap={4} class="items-center">
172
+ <Stack gap={4}>
155
173
  {#if backupState === "failed" && onRetry}
156
- <Button shape="round" onclick={onRetry}>Try again</Button>
174
+ <HStack gap={4} class="items-center">
175
+ <Button shape="round" onclick={onRetry}>Try again</Button>
176
+ </HStack>
157
177
  {/if}
158
178
 
159
179
  {#if errors.length > 0}
160
- <Button
161
- variant="ghost"
162
- shape="round"
163
- onclick={() => (showErrors = !showErrors)}
164
- >
165
- {showErrors ? "Hide details" : "View details"}
166
- </Button>
180
+ <Scrollable class="max-h-64">
181
+ <Stack gap={2}>
182
+ {#each errors as error, index (index)}
183
+ <Alert color={backupState === "failed" ? "danger" : "warning"}>
184
+ {error}
185
+ </Alert>
186
+ {/each}
187
+ </Stack>
188
+ </Scrollable>
167
189
  {/if}
168
- </HStack>
169
-
170
- {#if showErrors}
171
- <Scrollable class="max-h-32">
172
- <Stack gap={1}>
173
- {#each errors as error, index (index)}
174
- <Alert color={backupState === "failed" ? "danger" : "warning"}>
175
- {error}
176
- </Alert>
177
- {/each}
178
- </Stack>
179
- </Scrollable>
180
- {/if}
190
+ </Stack>
181
191
  {/if}
182
192
 
183
193
  {#if backupState === "running"}
184
194
  <Text color="muted">{running[type]}</Text>
185
- {:else if backupState === "complete" && details}
195
+ {:else if (backupState === "complete" || backupState === "warned") && details}
186
196
  <Text color="muted">{@render details()}</Text>
187
197
  {/if}
188
198
 
@@ -1,5 +1,5 @@
1
1
  export type BackupStatusType = "backup" | "restore" | "forget";
2
- export type BackupStatusState = "connecting" | "running" | "complete" | "failed";
2
+ export type BackupStatusState = "connecting" | "running" | "complete" | "warned" | "failed";
3
3
  import type { Snippet } from "svelte";
4
4
  type Props = {
5
5
  title: string;
@@ -1,12 +1,12 @@
1
1
  <script lang="ts">
2
2
  import BackendsList from "../../backends/BackendsList.svelte";
3
+ import PathListField from "../../ui/PathListField.svelte";
3
4
  import { type LocalRepositoryDto } from "../../../fetch-client";
4
5
  import { options } from "../../../options";
5
6
  import {
6
7
  useRemoveRepository,
7
8
  useUpdateRepository,
8
9
  } from "../../../services/repository.service";
9
- import PathListField from "../../ui/PathListField.svelte";
10
10
  import {
11
11
  Button,
12
12
  Field,
@@ -89,10 +89,8 @@
89
89
  </PathListField>
90
90
  {/if}
91
91
 
92
- <Button
93
- color="danger"
94
- loading={removeMutation.isPending}
95
- onclick={onRemove}>Remove Repository</Button
92
+ <Button color="danger" loading={removeMutation.isPending} onclick={onRemove}
93
+ >Remove Repository from This Device</Button
96
94
  >
97
95
 
98
96
  {#if advanced}
@@ -57,11 +57,20 @@
57
57
  return "connecting";
58
58
  }
59
59
 
60
- if (run.status === "incomplete") {
61
- return "running";
60
+ switch (run.status) {
61
+ case "incomplete": {
62
+ return "running";
63
+ }
64
+ case "failed": {
65
+ return "failed";
66
+ }
67
+ case "warn": {
68
+ return "warned";
69
+ }
70
+ default: {
71
+ return "complete";
72
+ }
62
73
  }
63
-
64
- return run.status === "failed" ? "failed" : "complete";
65
74
  });
66
75
 
67
76
  const duration = $derived(
@@ -94,6 +103,9 @@
94
103
  case "complete": {
95
104
  return `${titles[type].done} complete`;
96
105
  }
106
+ case "warned": {
107
+ return `${titles[type].done} completed with warnings`;
108
+ }
97
109
  case "failed": {
98
110
  return `${titles[type].done} failed`;
99
111
  }
@@ -38,6 +38,13 @@
38
38
  icon: mdiCloudOffOutline,
39
39
  } as const;
40
40
  }
41
+ case "warn": {
42
+ return {
43
+ title: `${noun.done} with warnings`,
44
+ color: "warning",
45
+ icon: mdiCloudCheckOutline,
46
+ } as const;
47
+ }
41
48
  case "incomplete": {
42
49
  return {
43
50
  title: `${noun.running} in progress`,
@@ -26,7 +26,9 @@
26
26
 
27
27
  let search = $state("");
28
28
  let newestFirst = $state(true);
29
- let status = $state<"all" | "complete" | "failed" | "incomplete">("all");
29
+ let status = $state<"all" | "complete" | "warn" | "failed" | "incomplete">(
30
+ "all",
31
+ );
30
32
  let page = $state(1);
31
33
 
32
34
  const onSearch = (value: string) => {
@@ -47,6 +49,7 @@
47
49
  const statusOptions: { value: typeof status; title: string }[] = [
48
50
  { value: "all", title: "All attempts" },
49
51
  { value: "complete", title: "Successful only" },
52
+ { value: "warn", title: "With warnings only" },
50
53
  { value: "failed", title: "Failed only" },
51
54
  { value: "incomplete", title: "In progress only" },
52
55
  ];
@@ -8,6 +8,7 @@
8
8
  CardTitle,
9
9
  HStack,
10
10
  } from "@immich/ui";
11
+ import { getBackupOutcome } from "../../utils/backup-status";
11
12
  import VisualisationSegmentedBar from "../ui/VisualisationSegmentedBar.svelte";
12
13
 
13
14
  type Props = {
@@ -25,18 +26,31 @@
25
26
  (tally, repo) => {
26
27
  if (local && !repo.backends?.primary.online) {
27
28
  tally.offline++;
28
- } else if (!repo.metrics?.lastBackup) {
29
- tally.neverRun++;
30
- } else if (
31
- repo.metrics.lastBackup === repo.metrics.lastSuccessfulBackup
32
- ) {
33
- tally.success++;
34
- } else {
35
- tally.failed++;
29
+ return tally;
36
30
  }
31
+
32
+ switch (getBackupOutcome(repo.metrics)) {
33
+ case "never": {
34
+ tally.neverRun++;
35
+ break;
36
+ }
37
+ case "failed": {
38
+ tally.failed++;
39
+ break;
40
+ }
41
+ case "warn": {
42
+ tally.warned++;
43
+ break;
44
+ }
45
+ default: {
46
+ tally.success++;
47
+ break;
48
+ }
49
+ }
50
+
37
51
  return tally;
38
52
  },
39
- { success: 0, offline: 0, failed: 0, neverRun: 0 },
53
+ { success: 0, offline: 0, warned: 0, failed: 0, neverRun: 0 },
40
54
  ),
41
55
  );
42
56
  </script>
@@ -69,6 +83,12 @@
69
83
  color: "var(--immich-ui-warning-500)",
70
84
  badge: "warning",
71
85
  },
86
+ {
87
+ value: status.warned,
88
+ label: "With warnings",
89
+ color: "var(--immich-ui-warning-500)",
90
+ badge: "warning",
91
+ },
72
92
  {
73
93
  value: status.failed,
74
94
  label: "Failed",
@@ -6,6 +6,7 @@
6
6
  mdiCloudOffOutline,
7
7
  mdiHistory,
8
8
  } from "@mdi/js";
9
+ import { getBackupOutcome } from "../../utils/backup-status";
9
10
  import MetricsHistoryModal from "../backups/metrics-history/MetricsHistoryModal.svelte";
10
11
  import StackList from "../ui/StackList.svelte";
11
12
  import StackListPlaceholder from "../ui/StackListPlaceholder.svelte";
@@ -54,19 +55,19 @@
54
55
  {/if}
55
56
 
56
57
  {#each recentAttempts as repository (repository.id)}
57
- {@const successful =
58
- repository.metrics.lastBackup === repository.metrics.lastSuccessfulBackup}
58
+ {@const outcome = getBackupOutcome(repository.metrics)}
59
+ {@const failed = outcome === "failed"}
59
60
 
60
61
  <StackListItem
61
62
  title={repository.name}
62
- color={successful ? "success" : "danger"}
63
+ color={failed ? "danger" : outcome === "warn" ? "warning" : "success"}
63
64
  actions={getActions(repository)}
64
65
  >
65
66
  {#snippet icon()}
66
- <Icon icon={successful ? mdiCloudCheckOutline : mdiCloudOffOutline} />
67
+ <Icon icon={failed ? mdiCloudOffOutline : mdiCloudCheckOutline} />
67
68
  {/snippet}
68
69
 
69
- {successful ? "Backed up" : "Attempted"}
70
+ {failed ? "Attempted" : outcome === "warn" ? "Backed up with warnings" : "Backed up"}
70
71
  <RelativeTime time={repository.metrics.lastBackup!} />
71
72
  </StackListItem>
72
73
  {/each}
@@ -17,6 +17,7 @@
17
17
  useScheduleEventHandler,
18
18
  useSchedules,
19
19
  } from "../../../services/schedule.service";
20
+ import { getBackupOutcome } from "../../../utils/backup-status";
20
21
  import { HStack, Icon, Text } from "@immich/ui";
21
22
  import {
22
23
  mdiChevronRight,
@@ -73,11 +74,7 @@
73
74
 
74
75
  const lastBackup = $derived(repository?.metrics.lastBackup ?? undefined);
75
76
 
76
- const failed = $derived(
77
- Boolean(
78
- lastBackup && lastBackup !== repository?.metrics.lastSuccessfulBackup,
79
- ),
80
- );
77
+ const outcome = $derived(getBackupOutcome(repository?.metrics));
81
78
 
82
79
  const loading = $derived(
83
80
  integrations.isLoading || repositories.isLoading || schedules.isLoading,
@@ -91,10 +88,14 @@
91
88
  return { color: "primary", icon: mdiCloudUploadOutline } as const;
92
89
  }
93
90
 
94
- if (failed) {
91
+ if (outcome === "failed") {
95
92
  return { color: "danger", icon: mdiCloudOffOutline } as const;
96
93
  }
97
94
 
95
+ if (outcome === "warn") {
96
+ return { color: "warning", icon: mdiCloudCheckVariantOutline } as const;
97
+ }
98
+
98
99
  if (paused) {
99
100
  return { color: "warning", icon: mdiCloudAlertOutline } as const;
100
101
  }
@@ -138,8 +139,10 @@
138
139
  <Text size="tiny" color={status.color} class="flex-1 truncate">
139
140
  {#if running}
140
141
  Backing up now
141
- {:else if failed}
142
+ {:else if outcome === "failed"}
142
143
  Backup failed
144
+ {:else if outcome === "warn" && lastBackup}
145
+ Backed up with warnings <RelativeTime time={lastBackup} />
143
146
  {:else if paused}
144
147
  Backups paused
145
148
  {:else if !configured}
@@ -4,6 +4,7 @@
4
4
  import RelativeTime from "../../util/RelativeTime.svelte";
5
5
  import type { LocalRepositoryDto, ScheduleDto } from "../../../fetch-client";
6
6
  import { handleCreateBackup } from "../../../services/repository.service";
7
+ import { getBackupOutcome } from "../../../utils/backup-status";
7
8
  import { Button, FormatBytes, Icon } from "@immich/ui";
8
9
  import {
9
10
  mdiAlert,
@@ -21,15 +22,23 @@
21
22
 
22
23
  const { repository, schedule }: Props = $props();
23
24
 
25
+ const outcome = $derived(getBackupOutcome(repository.metrics));
26
+
24
27
  const status = $derived.by(() => {
25
- if (!repository.metrics.lastBackup) {
26
- return { color: "warning", icon: mdiInformation } as const;
28
+ switch (outcome) {
29
+ case "never": {
30
+ return { color: "warning", icon: mdiInformation } as const;
31
+ }
32
+ case "failed": {
33
+ return { color: "danger", icon: mdiAlert } as const;
34
+ }
35
+ case "warn": {
36
+ return { color: "warning", icon: mdiAlert } as const;
37
+ }
38
+ default: {
39
+ return { color: "success", icon: mdiCheck } as const;
40
+ }
27
41
  }
28
-
29
- return repository.metrics.lastBackup !==
30
- repository.metrics.lastSuccessfulBackup
31
- ? ({ color: "danger", icon: mdiAlert } as const)
32
- : ({ color: "success", icon: mdiCheck } as const);
33
42
  });
34
43
 
35
44
  </script>
@@ -64,13 +73,17 @@
64
73
  {#snippet footer()}
65
74
  <Icon icon={status.icon} />
66
75
 
67
- {#if !repository.metrics.lastBackup}
76
+ {#if outcome === "never"}
68
77
  Backup is yet to run.
69
- {:else if status.color === "danger"}
70
- Last backup failed <RelativeTime time={repository.metrics.lastBackup} />
78
+ {:else if outcome === "failed"}
79
+ Last backup failed <RelativeTime time={repository.metrics.lastBackup!} />
80
+ {:else if outcome === "warn"}
81
+ Last backup finished with warnings <RelativeTime
82
+ time={repository.metrics.lastBackup!}
83
+ />
71
84
  {:else}
72
85
  Last backup successful <RelativeTime
73
- time={repository.metrics.lastBackup}
86
+ time={repository.metrics.lastBackup!}
74
87
  />
75
88
  {/if}
76
89
  {/snippet}
@@ -6,7 +6,7 @@
6
6
  import OnboardingStepConnectAccount from "../../onboarding/steps/OnboardingStep2ConnectAccount.svelte";
7
7
  import OnboardingStepSaveRecoveryKey from "../../onboarding/steps/OnboardingStep3SaveRecoveryKey.svelte";
8
8
  import OnboardingStepFirstBackup from "../../onboarding/steps/OnboardingStep4FirstBackup.svelte";
9
- import type { OnboardingStatusResponseDto } from "../../../fetch-client";
9
+ import { type OnboardingStatusResponseDto } from "../../../fetch-client";
10
10
  import {
11
11
  handleSetupLocalStorage,
12
12
  handleStartYuccaLogin,
@@ -78,11 +78,11 @@
78
78
  const onCancel = () => (stage = "idle");
79
79
 
80
80
  const afterTelemetry = () =>
81
- (stage = status?.hasOnboardedKey && status.hasBackup
82
- ? "finished"
83
- : "connect");
81
+ (stage =
82
+ status?.hasOnboardedKey && status.hasBackup ? "finished" : "connect");
84
83
 
85
- const onBackendReady = () => (stage = status?.hasOnboardedKey ? "backup" : "key");
84
+ const onBackendReady = () =>
85
+ (stage = status?.hasOnboardedKey ? "backup" : "key");
86
86
 
87
87
  const onConnect = () => {
88
88
  storageLocation = "FUTO Backups";
@@ -105,6 +105,16 @@
105
105
  }
106
106
  };
107
107
 
108
+ const onImportedKey = async (key: string) => {
109
+ try {
110
+ await handleConfirmRecoveryKey();
111
+ code = key;
112
+ stage = "connect";
113
+ } catch {
114
+ // no-op
115
+ }
116
+ };
117
+
108
118
  const onStartBackup = () =>
109
119
  defaults.mutate(undefined, { onSuccess: () => (stage = "finished") });
110
120
  </script>
@@ -142,10 +152,7 @@
142
152
  {:else if stage === "key-import"}
143
153
  <OnboardingStageKeyImport
144
154
  onStart={() => (stage = "intro")}
145
- onImported={(key) => {
146
- code = key;
147
- stage = "connect";
148
- }}
155
+ onImported={onImportedKey}
149
156
  {onCancel}
150
157
  />
151
158
  {:else if stage === "key"}
@@ -1,10 +1,10 @@
1
1
  <script lang="ts">
2
+ import type { OnboardingStatusResponseDto } from "../../fetch-client";
3
+ import { handleOnboardingStatus } from "../../services/onboarding.service";
2
4
  import { LoadingSpinner } from "@immich/ui";
3
5
  import { onMount, type Snippet } from "svelte";
4
6
  import OnboardingBootstrapError from "./OnboardingBootstrapError.svelte";
5
7
  import SampleOnboarding from "./SampleOnboarding.svelte";
6
- import type { OnboardingStatusResponseDto } from "../../fetch-client";
7
- import { handleOnboardingStatus } from "../../services/onboarding.service";
8
8
 
9
9
  type Props = {
10
10
  onExit: () => void;
@@ -37,7 +37,7 @@
37
37
  <LoadingSpinner />
38
38
  {:else if onboarding.status === "error"}
39
39
  <OnboardingBootstrapError error={onboarding.error} onQuit={onExit} />
40
- {:else if onboarding.hasTelemetry === "none" || !(onboarding.hasBackend && onboarding.hasOnboardedKey && (onboarding.hasSkippedExtraConfig || (onboarding.hasBackup && onboarding.hasSchedule)))}
40
+ {:else if onboarding.hasTelemetry === "none" || !(onboarding.hasBackend && onboarding.hasOnboardedKey)}
41
41
  <SampleOnboarding
42
42
  status={onboarding}
43
43
  onFinish={() => (onFinish ? onFinish() : onSkip())}
@@ -7,12 +7,9 @@
7
7
  import {
8
8
  handleConfirmRecoveryKey,
9
9
  handleCurrentRecoveryKey,
10
- handleSkipOnboardingExtraConfig,
11
10
  } from "../../services/onboarding.service";
12
11
  import { Modal, ModalBody } from "@immich/ui";
13
12
  import { onMount } from "svelte";
14
- import CreateFirstBackup from "./stages/SampleCreateFirstBackup.svelte";
15
- import CreateFirstSchedule from "./stages/SampleCreateFirstSchedule.svelte";
16
13
  import ImportKey from "./stages/OnboardingStageKeyImport.svelte";
17
14
  import Telemetry from "./stages/OnboardingStageTelemetry.svelte";
18
15
  import StepFinishSetup from "./steps/OnboardingStep1FinishSetup.svelte";
@@ -31,23 +28,12 @@
31
28
  let confirming = $state(false);
32
29
 
33
30
  // svelte-ignore state_referenced_locally
34
- let stage:
35
- | "intro"
36
- | "telemetry"
37
- | "key"
38
- | "key-import"
39
- | "connect"
40
- | "backup-create"
41
- | "schedule-create" = $state(
31
+ let stage: "intro" | "telemetry" | "key" | "key-import" | "connect" = $state(
42
32
  !status.hasOnboardedKey
43
33
  ? "intro"
44
34
  : status.hasTelemetry === "none"
45
35
  ? "telemetry"
46
- : !status.hasBackend
47
- ? "connect"
48
- : !status.hasBackup
49
- ? "backup-create"
50
- : "schedule-create",
36
+ : "connect",
51
37
  );
52
38
 
53
39
  onMount(() => {
@@ -56,11 +42,6 @@
56
42
  }
57
43
  });
58
44
 
59
- const onSkip = () => {
60
- void handleSkipOnboardingExtraConfig();
61
- onFinish();
62
- };
63
-
64
45
  const onConfirmKey = async () => {
65
46
  confirming = true;
66
47
 
@@ -77,7 +58,15 @@
77
58
  }
78
59
  };
79
60
 
80
- const onBackendReady = () => (stage = "backup-create");
61
+ const onTelemetryConfirmed = () => {
62
+ if (!status.hasOnboardedKey) {
63
+ stage = "key";
64
+ } else if (status.hasBackend) {
65
+ onFinish();
66
+ } else {
67
+ stage = "connect";
68
+ }
69
+ };
81
70
  </script>
82
71
 
83
72
  {#if stage === "intro"}
@@ -91,21 +80,15 @@
91
80
  </ModalBody>
92
81
  </Modal>
93
82
  {:else if stage === "telemetry"}
94
- <Telemetry
95
- onContinue={() =>
96
- (stage = !status.hasOnboardedKey
97
- ? "key"
98
- : !status.hasBackend
99
- ? "connect"
100
- : !status.hasBackup
101
- ? "backup-create"
102
- : "schedule-create")}
103
- {onCancel}
104
- />
83
+ <Telemetry onContinue={onTelemetryConfirmed} {onCancel} />
105
84
  {:else if stage === "key"}
106
85
  <Modal size="small" title="FUTO Backups" onClose={onCancel}>
107
86
  <ModalBody>
108
- <StepSaveRecoveryKey {code} onContinue={onConfirmKey} loading={confirming} />
87
+ <StepSaveRecoveryKey
88
+ {code}
89
+ onContinue={onConfirmKey}
90
+ loading={confirming}
91
+ />
109
92
  </ModalBody>
110
93
  </Modal>
111
94
  {:else if stage === "key-import"}
@@ -121,13 +104,9 @@
121
104
  <Modal size="small" title="FUTO Backups" onClose={onCancel}>
122
105
  <ModalBody>
123
106
  <StepConnectAccount
124
- onConnect={() => handleStartYuccaLogin(onBackendReady)}
125
- onLocalStorage={() => handleSetupLocalStorage(onBackendReady)}
107
+ onConnect={() => handleStartYuccaLogin(onFinish)}
108
+ onLocalStorage={() => handleSetupLocalStorage(onFinish)}
126
109
  />
127
110
  </ModalBody>
128
111
  </Modal>
129
- {:else if stage === "backup-create"}
130
- <CreateFirstBackup onNext={() => (stage = "schedule-create")} {onSkip} />
131
- {:else if stage === "schedule-create"}
132
- <CreateFirstSchedule {onFinish} {onSkip} />
133
112
  {/if}
@@ -88,12 +88,17 @@
88
88
  const hasFailedItems = (task: LiveTask) =>
89
89
  task.scheduleStatus?.some((item) => item.status === "failed") ?? false;
90
90
 
91
+ const hasWarnItems = (task: LiveTask) =>
92
+ task.scheduleStatus?.some((item) => item.status === "warn") ?? false;
93
+
91
94
  const statusColor = (task: LiveTask) =>
92
95
  !task.completedAt
93
96
  ? "var(--immich-ui-warning-500)"
94
97
  : hasFailedItems(task)
95
98
  ? "var(--immich-ui-danger-500)"
96
- : "var(--immich-ui-success-500)";
99
+ : hasWarnItems(task)
100
+ ? "var(--immich-ui-warning-500)"
101
+ : "var(--immich-ui-success-500)";
97
102
 
98
103
  onMount(async () => {
99
104
  const taskData = await handleGetRunningTasks();
@@ -210,6 +215,12 @@
210
215
  size="16"
211
216
  class="text-danger-500"
212
217
  />
218
+ {:else if item.status === "warn"}
219
+ <Icon
220
+ icon={mdiCheckCircleOutline}
221
+ size="16"
222
+ class="text-warning-500"
223
+ />
213
224
  {:else if subLog && subLog.progress > 0}
214
225
  <Icon
215
226
  icon={mdiLoading}
@@ -232,6 +243,8 @@
232
243
  <Badge size="tiny" color="success">Done</Badge>
233
244
  {:else if item.status === "failed"}
234
245
  <Badge size="tiny" color="danger">Failed</Badge>
246
+ {:else if item.status === "warn"}
247
+ <Badge size="tiny" color="warning">Warnings</Badge>
235
248
  {:else if subLog && subLog.progress > 0}
236
249
  <Badge size="tiny" color="warning"
237
250
  >{Math.round(subLog.progress * 100)}%</Badge
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { LocalRepositoryDto } from "../../../fetch-client";
3
+ import { getBackupOutcome } from "../../../utils/backup-status";
3
4
  import { Alert, Text } from "@immich/ui";
4
5
  import SegmentedBar from "../../ui/VisualisationSegmentedBar.svelte";
5
6
 
@@ -16,14 +17,12 @@
16
17
  (tally, repo) => {
17
18
  if (!repo.backends?.primary.online) {
18
19
  tally.offline++;
19
- } else if (!repo.metrics?.lastBackup) {
20
+ } else if (getBackupOutcome(repo.metrics) === "never") {
20
21
  tally.neverRun++;
21
- } else if (
22
- repo.metrics.lastBackup === repo.metrics.lastSuccessfulBackup
23
- ) {
24
- tally.success++;
25
- } else {
22
+ } else if (getBackupOutcome(repo.metrics) === "failed") {
26
23
  tally.failed++;
24
+ } else {
25
+ tally.success++;
27
26
  }
28
27
  return tally;
29
28
  },
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import RelativeTime from "../../util/RelativeTime.svelte";
3
3
  import type { LocalRepositoryDto } from "../../../fetch-client";
4
+ import { getBackupOutcome } from "../../../utils/backup-status";
4
5
  import { formatDuration } from "../../../utils/format";
5
6
  import {
6
7
  Button,
@@ -62,7 +63,7 @@
62
63
  {/if}
63
64
  <HStack class="items-center justify-between py-2">
64
65
  <HStack class="items-center gap-2">
65
- {#if repo.metrics.lastBackup === repo.metrics.lastSuccessfulBackup}
66
+ {#if getBackupOutcome(repo.metrics) !== "failed"}
66
67
  <Icon
67
68
  icon={mdiCheckCircleOutline}
68
69
  size="16"
@@ -16,6 +16,7 @@
16
16
  } from "@immich/ui";
17
17
  import {
18
18
  mdiArrowUp,
19
+ mdiCheck,
19
20
  mdiClose,
20
21
  mdiFileOutline,
21
22
  mdiFolderOutline,
@@ -149,7 +150,12 @@
149
150
  Browse
150
151
  {/snippet}
151
152
 
152
- <HStack gap={2} class="items-center px-4 py-3 bg-subtle">
153
+ <HStack
154
+ gap={2}
155
+ class="items-center px-4 py-3 {selected.has(listing.path)
156
+ ? 'bg-primary/10'
157
+ : 'bg-subtle'}"
158
+ >
153
159
  <Icon icon={mdiFolderOutline} color="primary" />
154
160
  <Text class="grow truncate" title={listing.path}>
155
161
  {listing.path}
@@ -167,7 +173,13 @@
167
173
 
168
174
  {#each sortedItems as item (item.path)}
169
175
  {#if item.isDirectory || !foldersOnly}
170
- <HStack gap={2} class="items-center px-2 py-1">
176
+ {@const isSelected = selected.has(item.path)}
177
+ <HStack
178
+ gap={2}
179
+ class="items-center px-2 py-1 {isSelected
180
+ ? 'bg-primary/10'
181
+ : ''}"
182
+ >
171
183
  {#if item.isDirectory}
172
184
  <ListButton
173
185
  leadingIcon={mdiFolderOutline}
@@ -189,11 +201,16 @@
189
201
  </HStack>
190
202
  {/if}
191
203
  <IconButton
192
- icon={mdiPlus}
193
- aria-label={single ? "Select" : "Add"}
204
+ icon={isSelected ? mdiCheck : mdiPlus}
205
+ aria-label={isSelected
206
+ ? "Selected"
207
+ : single
208
+ ? "Select"
209
+ : "Add"}
194
210
  size="tiny"
195
211
  variant="ghost"
196
- disabled={!single && selected.has(item.path)}
212
+ color={isSelected ? "primary" : undefined}
213
+ disabled={!single && isSelected}
197
214
  onclick={() => add(item.path)}
198
215
  />
199
216
  </HStack>
@@ -113,9 +113,10 @@ export type RepositoryCreateRequestDto = {
113
113
  site?: string;
114
114
  paths?: string[];
115
115
  };
116
+ export type TaskStatus = "incomplete" | "complete" | "warn" | "failed";
116
117
  export type RepositoryMetricsDto = {
117
118
  lastBackup?: string;
118
- lastSuccessfulBackup?: string;
119
+ lastBackupStatus?: TaskStatus;
119
120
  lastBackupDuration?: number;
120
121
  sizeBytes: number;
121
122
  };
@@ -202,7 +203,7 @@ export type RepositoryCheckImportResponseDto = {
202
203
  export type RepositoryPrimaryBackendReconfigureRequestDto = {
203
204
  backendId: string;
204
205
  };
205
- export type RunStatus = "incomplete" | "complete" | "failed";
206
+ export type RunStatus = "incomplete" | "complete" | "warn" | "failed";
206
207
  export type RunType = "schedule" | "restore" | "backup" | "forget";
207
208
  export type RunDto = {
208
209
  id: string;
@@ -231,7 +232,6 @@ export type RunResponseDto = {
231
232
  run: RunDto;
232
233
  };
233
234
  export type TaskType = "schedule" | "restore" | "backup" | "forget";
234
- export type TaskStatus = "incomplete" | "complete" | "failed";
235
235
  export type ActiveScheduleItemDto = {
236
236
  repositoryId: string;
237
237
  status: TaskStatus;
@@ -69,6 +69,12 @@ export function createLogObserver(logId) {
69
69
  flush.flush();
70
70
  break;
71
71
  }
72
+ case 'raw': {
73
+ state.errors.push(event.message);
74
+ flush();
75
+ flush.flush();
76
+ break;
77
+ }
72
78
  case 'yucca_prune_kept': {
73
79
  state.pruned.kept++;
74
80
  flush();
@@ -0,0 +1,3 @@
1
+ import type { RepositoryMetricsDto } from '../fetch-client';
2
+ export type BackupOutcome = 'never' | 'complete' | 'warn' | 'failed';
3
+ export declare const getBackupOutcome: (metrics: RepositoryMetricsDto | undefined) => BackupOutcome;
@@ -0,0 +1,16 @@
1
+ export const getBackupOutcome = (metrics) => {
2
+ if (!metrics?.lastBackup) {
3
+ return 'never';
4
+ }
5
+ switch (metrics.lastBackupStatus) {
6
+ case 'failed': {
7
+ return 'failed';
8
+ }
9
+ case 'warn': {
10
+ return 'warn';
11
+ }
12
+ default: {
13
+ return 'complete';
14
+ }
15
+ }
16
+ };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@futo-org/backups-orchestrator-ui",
3
3
  "repository": "https://github.com/immich-app/yucca",
4
4
  "description": "Backups orchestrator (UI library)",
5
- "version": "0.35.1",
5
+ "version": "0.37.0",
6
6
  "license": "Source First License 1.1",
7
7
  "files": [
8
8
  "dist",
@@ -64,7 +64,7 @@
64
64
  "lodash.debounce": "^4.0.8",
65
65
  "luxon": "^3.7.2",
66
66
  "socket.io-client": "^4.8.3",
67
- "@futo-org/backups-api-client": "^0.35.1"
67
+ "@futo-org/backups-api-client": "^0.37.0"
68
68
  },
69
69
  "scripts": {
70
70
  "dev": "vite dev --port 5174",
@@ -1,43 +0,0 @@
1
- <script lang="ts">
2
- import { Checkbox, Field, FormModal, Input, Stack } from "@immich/ui";
3
- import { useCreateRepository } from "../../../services/repository.service";
4
- import { SvelteSet } from "svelte/reactivity";
5
-
6
- type Props = {
7
- onNext: () => void;
8
- onSkip: () => void;
9
- };
10
-
11
- let { onNext, onSkip }: Props = $props();
12
-
13
- let name = $state("");
14
- let worm = $state(false);
15
- let paths = new SvelteSet([]);
16
-
17
- const mutation = useCreateRepository();
18
-
19
- const onSubmit = () =>
20
- mutation.mutate(
21
- { name, worm, paths: [...paths] },
22
- { onSuccess: () => onSkip() },
23
- );
24
- </script>
25
-
26
- <FormModal
27
- title="Create Your First Backup"
28
- disabled={name.length === 0 || mutation.isPending}
29
- onClose={onSkip}
30
- {onSubmit}
31
- >
32
- <Stack gap={4}>
33
- <Field label="Name" description="A memorable name for this backup">
34
- <Input bind:value={name} />
35
- </Field>
36
- <Field
37
- label="Write once (WORM)"
38
- description="Prevent anything being deleted"
39
- >
40
- <Checkbox bind:checked={worm} />
41
- </Field>
42
- </Stack>
43
- </FormModal>
@@ -1,7 +0,0 @@
1
- type Props = {
2
- onNext: () => void;
3
- onSkip: () => void;
4
- };
5
- declare const SampleCreateFirstBackup: import("svelte").Component<Props, {}, "">;
6
- type SampleCreateFirstBackup = ReturnType<typeof SampleCreateFirstBackup>;
7
- export default SampleCreateFirstBackup;
@@ -1,49 +0,0 @@
1
- <script lang="ts">
2
- import { Field, FormModal, Input, Stack } from "@immich/ui";
3
- import validate from "cron-validate";
4
- import { useCreateSchedule } from "../../../services/schedule.service";
5
- import { useRepositories } from "../../../services/repository.service";
6
-
7
- type Props = {
8
- onFinish: () => void;
9
- onSkip: () => void;
10
- };
11
-
12
- const { onFinish, onSkip }: Props = $props();
13
-
14
- const query = useRepositories();
15
- const repositories = $derived(
16
- (query.data ?? []).filter((repository) => repository.configuration),
17
- );
18
-
19
- let name = $state("");
20
- let cron = $state("*/15 * * * *");
21
-
22
- const mutation = useCreateSchedule();
23
-
24
- const onSubmit = () =>
25
- mutation.mutate(
26
- {
27
- name,
28
- cron,
29
- repositories: repositories.map((repository) => repository.id),
30
- },
31
- { onSuccess: () => onFinish() },
32
- );
33
- </script>
34
-
35
- <FormModal
36
- title="Create First Schedule"
37
- disabled={name.length === 0 || validate(cron).isError() || mutation.isPending}
38
- onClose={onSkip}
39
- {onSubmit}
40
- >
41
- <Stack gap={4}>
42
- <Field label="Name" description="Give this schedule a name">
43
- <Input bind:value={name} />
44
- </Field>
45
- <Field label="Schedule" description="Uses cron syntax">
46
- <Input bind:value={cron} />
47
- </Field>
48
- </Stack>
49
- </FormModal>
@@ -1,7 +0,0 @@
1
- type Props = {
2
- onFinish: () => void;
3
- onSkip: () => void;
4
- };
5
- declare const SampleCreateFirstSchedule: import("svelte").Component<Props, {}, "">;
6
- type SampleCreateFirstSchedule = ReturnType<typeof SampleCreateFirstSchedule>;
7
- export default SampleCreateFirstSchedule;