chore: general clean up (#134)

This commit is contained in:
Paul Makles
2026-06-22 13:37:21 +00:00
committed by GitHub
parent cc09a64eb6
commit 3e9f844c8e
21 changed files with 178 additions and 167 deletions
+19 -65
View File
@@ -1,68 +1,22 @@
#
# Restic API
#
# 1password
OP_ACCOUNT="team-futo.1password.com"
# RESTIC_API_PORT=
# OIDC: Zitadel staging
# WEB_PORT="5173"
# OIDC_ISSUER="https://external-dev-gkhk8b.us1.zitadel.cloud"
# OIDC_CLIENT_ID="op://yucca_tf_dev/CUSTOMER_ZITADEL_OAUTH_CLIENT_ID_DEV_TEST/password"
# OIDC_CLIENT_SECRET="op://yucca_tf_dev/CUSTOMER_ZITADEL_OAUTH_CLIENT_SECRET_DEV_TEST/password"
# OIDC_REDIRECT_URI="http://localhost:5173/api/auth/oidc/callback"
# OIDC_LOGOUT_REDIRECT_URI="http://localhost:5173"
### JWT Verify
# OIDC: Zitadel testing
# OIDC_DEVICE_ISSUER="https://zitadel.raccoon.sh"
# OIDC_DEVICE_CLIENT_ID="370711051198529553"
# OIDC_CLIENT_ID="368822166944284767"
# OIDC_CLIENT_SECRET=""
# JWT_PUBLIC_KEY=
### S3
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
# S3_REGION=
# S3_ENDPOINT=
# S3_FORCE_PATH_STYLE=
#
# Yucca API
#
# YUCCA_API_PORT=
### JWT Signing
# Generate with:
# openssl ecparam -name prime256v1 -genkey -noout -out priv.pem
# openssl pkcs8 -topk8 -nocrypt -in priv.pem -out priv-pkcs8.pem
# openssl ec -in priv.pem -pubout -out pub.pem
# JWT_PRIVATE_KEY=
# JWT_EXPIRES_IN=
### Postgres DB
# POSTGRES_HOST=
# POSTGRES_PORT=
# POSTGRES_USERNAME=
# POSTGRES_PASSWORD=
# POSTGRES_DATABASE=
# POSTGRES_SSL=
### Customer OIDC configuration
OIDC_ISSUER="https://external-dev-gkhk8b.us1.zitadel.cloud"
OIDC_CLIENT_ID="op://yucca_tf_dev/CUSTOMER_ZITADEL_OAUTH_CLIENT_ID_DEV_TEST/password"
OIDC_CLIENT_SECRET="op://yucca_tf_dev/CUSTOMER_ZITADEL_OAUTH_CLIENT_SECRET_DEV_TEST/password"
# OIDC_ALLOW_INSECURE=
# OIDC_REQUIRE_PKCE=
# OIDC_REDIRECT_URI=
# OIDC_LOGOUT_REDIRECT_URI=
# OIDC_SCOPE=
### ... temp
# RESTIC_API_HOST=
# RESTIC_API_PORT=
# RESTIC_ENDPOINT=
#
# Development only
#
# WEB_PORT=
# ORCHESTRATOR_WEB_PORT=
# Polar
# POLAR_ACCESS_TOKEN=
# POLAR_WEBHOOK_SECRET=
# POLAR_PRODUCT_ID=d9bc9054-eb91-46c9-846e-dd5bbb8f0041
# POLAR_APPLY_DISCOUNT_ID=391d9f04-889a-44ff-95f1-98cee8a5b0db
+8 -2
View File
@@ -35,8 +35,14 @@ REPO_ROOT="$PWD"
# ─── Configuration ──────────────────────────────────────────────────────────────
API_PORT="${RESTIC_API_PORT:-3010}"
API_HOST="${RESTIC_API_HOST:-localhost}"
RESTIC_ENDPOINT="${RESTIC_ENDPOINT:-http://localhost:3010}"
# bench spawns + port-manages the backend, so split the endpoint back into host/port
_endpoint_hostport="${RESTIC_ENDPOINT#*://}"
API_HOST="${_endpoint_hostport%%:*}"
API_PORT="${_endpoint_hostport##*:}"
API_PORT="${API_PORT%%/*}"
# the restic-api / michael server bench launches binds RESTIC_API_PORT
export RESTIC_API_PORT="$API_PORT"
export JWT_SECRET="${JWT_SECRET:-cca13c34b450a77c1d4b9ecd25dff6aebc6d7417afdb31864f5943c59abd03a1}"
BACKEND=""
+1 -2
View File
@@ -26,5 +26,4 @@ export OIDC_DEVICE_ISSUER=${OIDC_DEVICE_ISSUER:-http://localhost:8092}
export OIDC_DEVICE_CLIENT_ID=${OIDC_DEVICE_CLIENT_ID:-device client ID}
export OIDC_DEVICE_CLIENT_SECRET=${OIDC_DEVICE_CLIENT_SECRET:-client secret}
# ... temp
export RESTIC_API_PORT=${RESTIC_API_PORT:-3010}
export RESTIC_ENDPOINT=${RESTIC_ENDPOINT:-http://localhost:3010}
+2 -4
View File
@@ -65,10 +65,8 @@ env:
value: "device client ID"
- name: OIDC_DEVICE_ALLOW_INSECURE
value: "true"
- name: RESTIC_API_HOST
value: yucca-michael
- name: RESTIC_API_PORT
value: "3010"
- name: RESTIC_ENDPOINT
value: http://yucca-michael:3010
- name: OTEL_METRICS
value: http://victoria-metrics:8428/opentelemetry/v1/metrics
- name: OTEL_LOGGING
+4 -1
View File
@@ -11,7 +11,10 @@
const { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<svelte:head>
<title>FUTO Backups</title>
<link rel="icon" href={favicon} />
</svelte:head>
<YuccaContext>
{@render children()}
</YuccaContext>
+18 -16
View File
@@ -1,22 +1,24 @@
<script lang="ts">
import { Button } from "@immich/ui";
import { t } from "svelte-i18n-lingui";
import { defaults } from "@futo-org/backups-api-client";
import { locale } from "svelte-i18n-lingui";
async function setLocale(lang: "en" | "ja") {
const { messages } = await import(`../locales/${lang}.ts`);
locale.set(lang, messages);
}
import { Button, Card, CardBody, Heading, VStack } from "@immich/ui";
import { t } from "svelte-i18n-lingui";
</script>
<main class="p-4">
<Button
onclick={() => (location.href = defaults.baseUrl + "api/auth/oidc/login")}
>{$t`Login`}</Button
>
<svelte:head><title>{$t`Login`} &middot; FUTO Backups</title></svelte:head>
<Button onclick={() => setLocale("en")}>Switch to English</Button>
<Button onclick={() => setLocale("ja")}>Switch to Test</Button>
<main class="flex min-h-screen items-center justify-center p-4">
<VStack gap={6} class="w-full max-w-sm items-center">
<Card>
<CardBody>
<VStack>
<Heading>FUTO Backups</Heading>
<Button
onclick={() =>
(location.href = defaults.baseUrl + "api/auth/oidc/login")}
>{$t`Login`}</Button
>
</VStack>
</CardBody>
</Card>
</VStack>
</main>
@@ -5,6 +5,8 @@
const { data } = $props();
</script>
<svelte:head><title>Dashboard &middot; FUTO Backups</title></svelte:head>
<Dashboard
initialData={data.initialData.repositories}
onNavigate={(route) => goto(`/dashboard/${route}`)}
@@ -4,4 +4,6 @@
const { data } = $props();
</script>
<svelte:head><title>Backups &middot; FUTO Backups</title></svelte:head>
<BackupsList initialData={data.initialData} />
+1
View File
@@ -1,3 +1,4 @@
@import "tailwindcss";
@import "@immich/ui/theme/default.css";
@source "../../node_modules/@immich/ui";
@source "../../node_modules/@futo-org/backups-orchestrator-ui";
@@ -9,6 +9,8 @@
} from "@immich/ui";
</script>
<svelte:head><title>Grant access &middot; FUTO Backups</title></svelte:head>
<Card>
<CardHeader>
<CardTitle>Grant access to app?</CardTitle>
+29 -37
View File
@@ -1,48 +1,40 @@
import type { StringValue } from 'ms';
import { z } from 'zod';
const schema = z
.object({
NODE_ENV: z.enum(['development', 'production', 'test', 'provision']).default('development'),
const schema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test', 'provision']).default('development'),
YUCCA_API_PORT: z.coerce.number().min(1000),
YUCCA_API_PORT: z.coerce.number().min(1000),
JWT_PRIVATE_KEY: z.string(),
JWT_EXPIRES_IN: z
.string()
.regex(/^\d+\s*(ms|s|m|h|d|w|y)$/i, 'Expected a duration like "1d", "30m", "3600s"')
.default('1d')
.transform((value): StringValue => value as StringValue),
JWT_PRIVATE_KEY: z.string(),
JWT_EXPIRES_IN: z
.string()
.regex(/^\d+\s*(ms|s|m|h|d|w|y)$/i, 'Expected a duration like "1d", "30m", "3600s"')
.default('1d')
.transform((value): StringValue => value as StringValue),
POSTGRES_HOST: z.string(),
POSTGRES_PORT: z.coerce.number().default(5432),
POSTGRES_USERNAME: z.string(),
POSTGRES_PASSWORD: z.string(),
POSTGRES_DATABASE: z.string(),
POSTGRES_SSL: z.union([z.enum(['require', 'allow', 'prefer', 'verify-full']), z.boolean()]).default(false),
POSTGRES_HOST: z.string(),
POSTGRES_PORT: z.coerce.number().default(5432),
POSTGRES_USERNAME: z.string(),
POSTGRES_PASSWORD: z.string(),
POSTGRES_DATABASE: z.string(),
POSTGRES_SSL: z.union([z.enum(['require', 'allow', 'prefer', 'verify-full']), z.boolean()]).default(false),
OIDC_ISSUER: z.url().transform((url) => new URL(url)),
OIDC_CLIENT_ID: z.string(),
OIDC_CLIENT_SECRET: z.string(),
OIDC_ALLOW_INSECURE: z.coerce.boolean().default(false),
OIDC_REQUIRE_PKCE: z.coerce.boolean().default(true),
OIDC_REDIRECT_URI: z.string(),
OIDC_LOGOUT_REDIRECT_URI: z.string(),
OIDC_SCOPE: z.string().default('openid profile email'),
OIDC_ISSUER: z.url().transform((url) => new URL(url)),
OIDC_CLIENT_ID: z.string(),
OIDC_CLIENT_SECRET: z.string(),
OIDC_ALLOW_INSECURE: z.coerce.boolean().default(false),
OIDC_REQUIRE_PKCE: z.coerce.boolean().default(true),
OIDC_REDIRECT_URI: z.string(),
OIDC_LOGOUT_REDIRECT_URI: z.string(),
OIDC_SCOPE: z.string().default('openid profile email'),
OIDC_DEVICE_ISSUER: z.url().transform((url) => new URL(url)),
OIDC_DEVICE_CLIENT_ID: z.string(),
OIDC_DEVICE_ALLOW_INSECURE: z.coerce.boolean().default(false),
OIDC_DEVICE_SCOPE: z.string().default('openid profile email'),
OIDC_DEVICE_ISSUER: z.url().transform((url) => new URL(url)),
OIDC_DEVICE_CLIENT_ID: z.string(),
OIDC_DEVICE_ALLOW_INSECURE: z.coerce.boolean().default(false),
OIDC_DEVICE_SCOPE: z.string().default('openid profile email'),
// temp.
RESTIC_API_HOST: z.string().default('localhost'),
RESTIC_API_PORT: z.coerce.number().min(1000),
RESTIC_ENDPOINT: z.string().optional(),
})
.transform(({ RESTIC_ENDPOINT, ...options }) => ({
...options,
RESTIC_ENDPOINT: RESTIC_ENDPOINT ?? `http://${options.RESTIC_API_HOST}:${options.RESTIC_API_PORT}`,
}));
RESTIC_ENDPOINT: z.string().default('http://localhost:3010'),
});
export const env = schema.parse(process.env);
@@ -27,7 +27,10 @@ class YuccaWellKnown {
private data?: WellKnown;
async get() {
this.data ??= await fetch(YUCCA_WELL_KNOWN).then((response) => response.json());
this.data ??= await fetch(YUCCA_WELL_KNOWN, { signal: AbortSignal.timeout(8000) }).then((response) =>
response.json(),
);
return this.data! as WellKnown;
}
@@ -71,10 +71,16 @@ export class AuthService {
onDisconnect: () => events.close(),
});
const connectTimeout = setTimeout(() => events.close(), 10_000);
for await (const { data } of events) {
clearTimeout(connectTimeout);
const { userCode, verificationUri } = JSON.parse(data);
void this.waitForDeviceFlow(events, overrideEndpoint).catch(() => {});
void this.waitForDeviceFlow(events, overrideEndpoint).catch((error) => {
this.telemetry.submitStructuredLog('Device flow authentication errored', { error });
this.events.publish({ type: 'DeviceFlowFailure' });
});
return {
userCode,
@@ -426,8 +426,11 @@ export class RepositoryService {
// ... in the future, this should push to all mirrors too
}
} catch {
// no-op
} catch (error) {
this.telemetry.submitStructuredLog('Failed to update repository metrics', {
repositoryId: id,
error,
});
}
}
@@ -671,8 +674,12 @@ export class RepositoryService {
const snapshots = await this.restic.snapshots(endpoint, key);
snapshots.sort((a, b) => +b.time - +a.time);
paths = snapshots[0].paths;
} catch {
// no-op
} catch (error) {
this.telemetry.submitStructuredLog('Failed to infer backup paths during import', {
repositoryId: id,
backendId,
error,
});
}
await this.repository.create({
@@ -5,7 +5,10 @@
LocalRepositoryDto,
RepositoryBackendDto,
} from "$lib/fetch-client";
import { getBackendActions } from "$lib/services/backend.service";
import {
getBackendActions,
useYuccaLogin,
} 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";
@@ -30,8 +33,12 @@
s3: "S3 Server",
};
const yuccaLogin = useYuccaLogin();
const { LoginAgain, Reconfigure } = $derived(
getBackendActions(repository, backend, repositoryBackend),
getBackendActions(repository, backend, repositoryBackend, () =>
yuccaLogin.mutate(undefined),
),
);
</script>
@@ -3,9 +3,9 @@
import { options } from "$lib/options";
import {
handleSetupLocalStorage,
handleYuccaLogin,
useBackendEventHandler,
useBackends,
useYuccaLogin,
} from "$lib/services/backend.service";
import { Button, HStack } from "@immich/ui";
import StackList from "../ui/StackList.svelte";
@@ -21,6 +21,7 @@
const { advanced } = options;
const query = useBackends();
const yuccaLogin = useYuccaLogin();
const { onBackendCreate } = useBackendEventHandler();
const repositoryBackends = $derived(
@@ -61,8 +62,11 @@
{#if $advanced}
<HStack>
<Button size="small" variant="outline" onclick={() => handleYuccaLogin()}
>Login with FUTO Backups</Button
<Button
size="small"
variant="outline"
loading={yuccaLogin.isPending}
onclick={() => yuccaLogin.mutate(undefined)}>Login with FUTO Backups</Button
>
<Button
size="small"
@@ -13,6 +13,7 @@
ModalFooter,
Stack,
Text,
toastManager,
VStack,
} from "@immich/ui";
import { mdiContentCopy } from "@mdi/js";
@@ -35,6 +36,11 @@
onClose();
}
function onDeviceFlowFailure() {
toastManager.danger("Failed to log into FUTO Backups");
onClose();
}
function onOpen() {
window.open(verificationUri, "_blank");
}
@@ -44,7 +50,7 @@
}
</script>
<OnEvents {onBackendCreate} />
<OnEvents {onBackendCreate} {onDeviceFlowFailure} />
<Modal title="Logging into FUTO Backups" icon={false} {onClose}>
<ModalBody>
@@ -4,13 +4,14 @@
import Suspense from "$lib/components/util/Suspense.svelte";
import {
handleSetupLocalStorage,
handleYuccaLogin,
useBackends,
useYuccaLogin,
} from "$lib/services/backend.service";
import {
Button,
HStack,
Icon,
LoadingSpinner,
Modal,
ModalBody,
ModalFooter,
@@ -37,6 +38,7 @@
}: Props = $props();
const backends = useBackends();
const yuccaLogin = useYuccaLogin();
const onFutoBackups = () => {
const futoBackend = backends.data!.find(
@@ -46,7 +48,7 @@
if (futoBackend) {
onSelect(futoBackend.id);
} else {
handleYuccaLogin(onSelect);
yuccaLogin.mutate(onSelect);
}
};
@@ -63,7 +65,7 @@
<Suspense query={backends}>
<StackList>
<StackListItem {disabled} onclick={onFutoBackups}>
<StackListItem disabled={disabled || yuccaLogin.isPending} onclick={onFutoBackups}>
{#snippet icon()}
<Icon icon={mdiShieldCheck} size="36px" />
{/snippet}
@@ -74,10 +76,14 @@
</Stack>
{#snippet trailing()}
<Icon icon={mdiChevronRight} />
{#if yuccaLogin.isPending}
<LoadingSpinner />
{:else}
<Icon icon={mdiChevronRight} />
{/if}
{/snippet}
</StackListItem>
<StackListItem {disabled} onclick={onLocalBackups}>
<StackListItem disabled={disabled || yuccaLogin.isPending} onclick={onLocalBackups}>
{#snippet icon()}
<Icon icon={mdiHarddisk} size="36px" />
{/snippet}
@@ -95,7 +101,7 @@
{#each backends.data as backend}
{#if backend.type === "local"}
<StackListItem
{disabled}
disabled={disabled || yuccaLogin.isPending}
onclick={() => {
onSelect(backend.id);
}}
@@ -53,9 +53,9 @@
<Heading size="tiny">Your library</Heading>
<Text>
{#if repository.meter}
Estimated <FormatBytes bytes={repository.meter.sizeBytes} />
<FormatBytes bytes={repository.meter.sizeBytes} />
{:else}
<FormatBytes bytes={repository.metrics.sizeBytes} />
Estimated <FormatBytes bytes={repository.metrics.sizeBytes} />
{/if} &middot;
<span class="lowercase"
>{cronstrue.toString(schedule.cron, {
@@ -3,12 +3,13 @@
import StackListItem from "$lib/components/ui/StackListItem.svelte";
import {
handleSetupLocalStorage,
handleYuccaLogin,
useYuccaLogin,
} from "$lib/services/backend.service";
import {
Button,
HStack,
Icon,
LoadingSpinner,
Modal,
ModalBody,
ModalFooter,
@@ -25,8 +26,10 @@
const { restore = false, onNext, onCancel }: Props = $props();
const yuccaLogin = useYuccaLogin();
function onFutoBackups() {
handleYuccaLogin(onNext);
yuccaLogin.mutate(onNext);
}
function onLocalBackups() {
@@ -44,7 +47,7 @@
>
<ModalBody>
<StackList>
<StackListItem onclick={onFutoBackups}>
<StackListItem disabled={yuccaLogin.isPending} onclick={onFutoBackups}>
{#snippet icon()}
<Icon icon={mdiShieldCheck} size="36px" />
{/snippet}
@@ -55,10 +58,14 @@
</Stack>
{#snippet trailing()}
<Icon icon={mdiChevronRight} />
{#if yuccaLogin.isPending}
<LoadingSpinner />
{:else}
<Icon icon={mdiChevronRight} />
{/if}
{/snippet}
</StackListItem>
<StackListItem onclick={onLocalBackups}>
<StackListItem disabled={yuccaLogin.isPending} onclick={onLocalBackups}>
{#snippet icon()}
<Icon icon={mdiHarddisk} size="36px" />
{/snippet}
@@ -48,22 +48,25 @@ export const useBackendEventHandler = () => {
};
};
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;
}
const startYuccaLogin = async (onCreate?: (backendId: string) => void) => {
const response = await oidcDeviceFlow();
void modalManager.show(OAuthDeviceFlow, {
...response,
onCreate,
});
window.open(response.verificationUri, '_blank');
};
export const useYuccaLogin = () =>
createMutation(
() => ({
mutationFn: (onCreate?: (backendId: string) => void) =>
startYuccaLogin(onCreate),
onError: (error) => handleError(error, 'Failed to start login'),
}),
() => queryClient,
);
export const handleSetupLocalStorage = (
onCreate?: (backendId: string) => void,
) => {
@@ -99,11 +102,12 @@ export const getBackendActions = (
repository: LocalRepositoryDto | undefined,
backend: BackendDto,
repositoryBackend?: RepositoryBackendDto & { primary?: boolean },
onLogin?: () => void,
) => {
const LoginAgain: ActionItem = {
icon: mdiLogin,
title: 'Login again',
onAction: () => void handleYuccaLogin(),
onAction: () => onLogin?.(),
$if: () => backend.type === 'yucca' && !backend.isOnline,
};