feat(web): connections ui (#415)

This commit is contained in:
Antoine Lecompte
2026-08-03 18:30:57 +00:00
committed by GitHub
parent b427a7045b
commit 8feda20065
10 changed files with 924 additions and 3 deletions
+53 -2
View File
@@ -114,9 +114,10 @@ to victoria-*).
| `yucca-admin-api` | NestJS | Admin API (user/session/repository management). Shares the same DB + JWT validation. |
| `michael` | Go | **Production** restic REST backend — S3 proxy implementing restic's HTTP protocol, with JWT (ECDSA pubkey) verification, WORM enforcement, multi-backend pool/DNS load-balancing. Deployed in k8s (`kubernetes/apps/base/michael`). |
| `restic-api` | NestJS | Earlier TypeScript implementation of the same restic backend, kept as a **reference** (`mise restic-api:dev-reference`); not in the deployed app set. |
| `yucca-metrics-worker` | NestJS | Cron worker (every 5 min): reads bucket usage from RadosGW, writes meter tables, emits OTel gauges. |
| `yucca-metrics-worker` | NestJS | Cron worker (every 5 min): reads bucket usage from RadosGW, writes meter tables, **rolls usage up per connection into `connectionMetrics` (with the per-type billing floor)**, emits OTel gauges. |
| `redis` (valkey) | | **Generic shared platform cache** (ephemeral by design; keys namespaced `yucca:<service>:<purpose>:*`). First tenant: michael's restic-token **verdict cache** (`yucca:michael:verdict:<jti>`, DELed by the APIs on revoke); future: michael rate limiting. In-repo chart `charts/apps/redis`; primary-region only. |
| `mock-oidc-provider` | Node | Dev/test OIDC IdP (code + device flow). Used by compose and k3d when no real issuer is configured. |
| `common` (`@common/server`) | TS lib | Shared OTel init, pino logger repository, logging interceptor. |
| `common` (`@common/server`) | TS lib | Shared OTel init, pino logger repository, logging interceptor, **the feature-flag registry (`FeatureFlags`) and connection types (`ConnectionTypes`)**. |
**Frontend** (`packages/web`) is **SvelteKit 5 + Tailwind 4**, using `@immich/ui`, lingui i18n
(`mise web:lingui:*` to extract/compile — compiled locales are generated, not edited), and the
@@ -134,6 +135,51 @@ generated API client. It also embeds the orchestration UI (`@futo-org/backups-or
consumed by web). `fetch-client.ts` is generated (eslint-ignored). When you change an API
contract, regenerate rather than editing the client.
### Connections, feature flags, and restic-token revocation
- **Connections** (`connections` table) make "what backs up this account" first-class: a user has N
connection instances of type `immich` or `restic`. Every repository has a `connectionId`
(NOT NULL); device-flow sessions bind to a connection via `?connection_type=&connection_name=` on
`/auth/oidc/device`. Existing repos were backfilled onto a default `immich` connection; instance
attribution is client-driven via `POST /connections/:id/adopt` (moves default-connection repos to a
named instance), never guessed server-side. The in-repo orchestrator (`yucca-sdk`) does this on
device-flow login: it registers as an `immich` connection named after its external host, then
best-effort adopts its existing repositories onto that instance. The `/connections` API surface
(list, create, adopt, manage, including multiple `immich` instances) is open to **every**
authenticated user.
- **Feature flags** = registry in code (`@common/server` `FeatureFlags`), strict-boolean per-user
overrides in `userFeatureFlagOverride`. Resolution is `override ?? registry default`; the default
flips at GA via a release (code-only defaults). Flags gate self-service use of the individual
non-default connection *type*, not the whole surface: `connection-restic`
(`experimental`, default off): `immich` needs none. The mapping lives in `@common/server`
`ConnectionTypeFlags`/`connectionTypeFlag()`, checked in `ConnectionService.create` and the device
flow; admin-provisioned connections bypass it (admin authority). `@RequireFeature` remains as the
generic route-level guard for future whole-route gating. Manage from yuctl: `users features
set/clear`, `features enable-batch`. **Boundary rule:** env/cluster-settings = deployment config
(ops-owned, per-partition); feature flags = per-user product gating (admin-owned, runtime).
- **Per-type descriptor + billing** live in the code registry (`@common/server` `ConnectionTypeInfos`):
each type declares its metering tiers, `reportsActivity`, `minObjectSizeBytes` (billing floor), and
`revocable`. Billing keys off the always-available **storage** tier: `yucca-metrics-worker` rolls each
connection's per-repo RadosGW readings up into `connectionMetrics` and computes
`billableBytes(type, size, objects) = max(size, objects * minObjectSizeBytes)` (immich floor 0; non-immich
1 MiB, an aggregate approximation, RadosGW gives no per-object histogram). `GET /connections` returns the
rollup. **Self-serve restic** (flagged): `POST /connections/restic` creates connection+repo+long-lived URL
in one shot; `POST /repository/:id/restic` mints for an existing repo, **long-lived is revocable-only**
(restic: default `RESTIC_JWT_EXPIRES_IN` 90d, `expiresIn` capped by `RESTIC_JWT_MAX_EXPIRES_IN` 365d;
immich: short `JWT_EXPIRES_IN` lifetime, custom `expiresIn` rejected: michael never validity-checks
non-revocable types, so they must not be long-lived); `GET /repository/:id/restic-tokens` +
`DELETE /restic-tokens/:jti` are owner-scoped. See `docs/connections.md`.
- **Restic-token revocation** = **postgres truth + layered caches, bounded grace**. michael checks a token's
liveness (revocable types only: `REVOCABLE_CONNECTION_TYPES`, default `restic`; immich is skipped) through:
L1 per-process cache (`REVOCATION_FRESH_TTL_MS` 60s fresh / `REVOCATION_GRACE_MS` 30min grace) → shared
valkey **verdict cache** (`yucca:michael:verdict:<jti>`, `VERDICT_CACHE_TTL_MS` 5min, read-through; errors
fall through) → yucca-api's internal introspection endpoint (`GET /internal/restic-tokens/:jti`, shared
secret `TOKEN_INTROSPECTION_SECRET`, answers `{active}` from `resticTokens`). Mint writes only the DB row;
revoke flips the row then best-effort **DELs the L2 key** (lands within ~L1 fresh; a missed DEL self-heals
via L2 TTL, no reconcile job). Valkey restart/outage = cache miss → postgres, harmless. Introspection outage:
previously-valid jtis honored for the grace window, then **fail closed**. Enforced only where
`TOKEN_INTROSPECTION_URL` is set (primary regions). yuctl: `tokens list/revoke`, `repos url --ttl`.
### Database
PostgreSQL accessed via **Kysely**. The schema lives in `packages/yucca-api/src/schema/`
@@ -188,6 +234,11 @@ release tag into both prod pins (`kubernetes/clusters/prod/htz-fsn1/{flux-releas
trailing commas, width 120.
- Generated files are eslint-ignored: `**/fetch-client.ts`, `packages/web/src/locales`, `dist`,
`build`, `.svelte-kit`.
- **Default to zero comments.** No narration, no restating what the code already says; make the
code self-explanatory instead. Add a comment only in the rare case it captures something the
code cannot (a why, a constraint, a gotcha) that a reader would otherwise miss.
- **Match the package you are in.** Read the surrounding code first and follow its existing
style and patterns; write code that looks like what is already there, not your own conventions.
### Naming
+171
View File
@@ -0,0 +1,171 @@
# Connections
A **connection** is what backs up a user's account. It makes "what is using this
account" first-class, so usage can be attributed and billed per source and so a
user can run more than one backup client against one account.
```
User ──1:N──> Connection ──1:N──> Repository ──1:1──> S3 bucket (via michael)
```
- **User**, the account and **billing unit** (plan/quota live here).
- **Connection**, attribution + capability + billing-rollup unit. Carries a
`type`; device-flow sessions bind to one; usage rolls up here.
- **Repository**, the restic repo / bucket; per-repo metering happens here
(`repositoryMeter`). Every repository has a NOT-NULL `connectionId`.
Connection→Repository is 1:N (a restic connection is reused across repositories);
a repository belongs to exactly one connection. `POST /connections/:id/adopt`
re-parents a repository that still sits on the user's **default** connection.
## Types (a code registry)
The set of connection types and their behavior is **code**, not data, only
per-user/instance state is data. The descriptor lives in
`packages/common/src/connections.ts` (`ConnectionTypeInfos`), exported from
`@common/server`. Adding a type is a one-object change there.
| Type | Metering tiers | Reports activity | Min object size | Revocable | Self-serve flag |
|---|---|---|---|---|---|
| `immich` | storage, transfer, activity | yes | 0 | no | none (always on) |
| `restic` | storage, transfer | no | 1 MiB | yes | `connection-restic` |
| `s3` *(future)* | storage | no | 1 MiB | yes |, |
### Metering tiers
Billing keys off **storage**, the only universal tier.
| Tier | Source | immich | restic | s3 |
|---|---|---|---|---|
| **Storage** (bytes, objects) → **billed** | RadosGW | ✅ | ✅ | ✅ |
| Transfer | michael | ✅ | ✅ | ❌ |
| Activity (backup start/end) | client | ✅ | ❌ | ❌ |
## Billing rollup
`yucca-metrics-worker` meters each repository from RadosGW every 5 minutes, then
rolls the readings up per connection into the `connectionMetrics` table
(`sizeBytes`, `objectCount`, `billableBytes`, `repositoryCount`). `GET /connections`
returns the rollup per connection.
**Billable bytes** apply a per-type object-size floor via
`billableBytes(type, sizeBytes, objectCount)`:
```
billableBytes = max(sizeBytes, objectCount * minObjectSizeBytes)
```
immich is exempt (floor 0 → billed at raw size). Non-immich types bill each
object at a minimum of 1 MiB. RadosGW exposes only total size + object count (no
per-object histogram), so this is an **aggregate approximation** of
`Σ max(objectSize_i, 1 MiB)`, it under-counts a repo that mixes large and small
objects, but restic writes large pack files so `sizeBytes` dominates and the
floor only bites for tiny/new repos or many-small-object raw-S3, the intended
cases. Exact per-object billing (S3 `ListObjects`) is a documented future option.
*(This produces billable-bytes only. Pricing/plan/quota is a separate later layer.)*
## Revocation: postgres truth, layered caches, bounded grace
restic tokens are long-lived, so their liveness is checked against the **source
of truth, postgres** (`resticTokens`), fronted by yucca-api's internal
introspection endpoint and two cache layers in michael:
```
michael request ──> L1 (per-process, fresh 60s / grace 30min)
└miss──> L2 (shared valkey, yucca:michael:verdict:<jti>, TTL 5min)
└miss/error──> GET yucca-api /internal/restic-tokens/:jti (postgres)
```
- **Introspection** (`GET /internal/restic-tokens/:jti`, shared-secret header
`X-Introspection-Secret`) answers `{active}`: minted, unrevoked, unexpired,
**owner enabled** (disabling an account kills its credentials; re-enabling
restores unexpired ones). Unknown, malformed, revoked, expired, and
disabled-owner jtis all answer `active:false`.
The route is **unreachable from the public internet**: the gateway
short-circuits `/api/internal/*` to a bare 404 (`HTTPRouteFilter
internal-404` shadowing the `/api` rule), so only pod-to-pod traffic,
admitted by `allow-ingress-yucca-api`, ever reaches it; the shared secret
is the second wall, not the only one.
- **Mint** writes only the postgres row, no cache writes; the first request
populates the caches read-through.
- **Revoke** flips the DB row, then best-effort **DELs the L2 verdict key**,
the revoke lands on every michael replica within ~the L1 fresh TTL
(`REVOCATION_FRESH_TTL_MS`, default 60 s). A *missed* DEL self-heals when the
L2 entry's TTL (`VERDICT_CACHE_TTL_MS`, default 5 min) lapses and the next
miss re-asks postgres, no reconcile job exists or is needed.
- **Valkey restart/outage is harmless**: L2 is pure cache, a miss or error
falls through to introspection. (This is why the old marker model's
restart-deny-window and reconcile cron are gone.)
- **Introspection outage** (yucca-api/postgres unreachable): michael keeps
honoring a *previously-valid* jti until a bounded **grace** window elapses
(`REVOCATION_GRACE_MS`, default 30 min), then fails **closed**. The horizon is
anchored to the last *authoritative* confirmation (L2 hits carry the entry's
age via PTTL), so 30 min is a true end-to-end bound; repeated failures are
also debounced (a short backoff gates introspection dials, so an outage never
turns restic's request concurrency into a control-plane storm). A jti never
confirmed valid is denied immediately, bounded grace, then deny.
- michael **skips** the check entirely for non-revocable types (immich, whose
access rides the device-flow session): `REVOCABLE_CONNECTION_TYPES` (default
`restic`) mirrors the descriptor's `revocable` set.
michael enforces validity only where `TOKEN_INTROSPECTION_URL` is set (primary
regions, secondaries have no local yucca-api and run with checking off). The
valkey is the **generic shared platform cache** (`charts/apps/redis`, ephemeral
by design, keys namespaced `yucca:<service>:<purpose>:*`); the verdict cache is
its first tenant, with michael rate limiting a likely second.
## Self-serve restic
A user with the `connection-restic` flag can stand up a restic backup in one call:
- **`POST /connections/restic`**, get-or-create the user's restic connection,
create a repository under it, mint a **long-lived** rest: URL, and return
`{ connection, repository, url, jti, expiresAt }`. Idempotent on the connection
(reused across repositories). Gated on `connection-restic` (403 without).
- **`POST /repository/:id/restic`**, mint a URL for an existing repository.
Optional `expiresIn` and `label`. **Long-lived tokens are revocable-only**: for
restic repositories the default is `RESTIC_JWT_EXPIRES_IN` (90d), capped at
`RESTIC_JWT_MAX_EXPIRES_IN` (365d); for non-revocable types (immich, michael
never validity-checks them) the token keeps the short session-JWT lifetime
(`JWT_EXPIRES_IN`, 1d) and a custom `expiresIn` is rejected.
- **`GET /repository/:id/restic-tokens`**, list a repository's minted tokens
(owner-scoped).
- **`DELETE /restic-tokens/:jti`**, revoke your own token (owner-scoped; unknown
or other-owner jtis 404 identically, so ownership isn't leaked). Invalidates
michael's cached verdict.
The `/connections` surface (list, create, adopt, manage) is open to **every**
authenticated user; the individual non-default *type* is flag-gated on every
**credential-creating** operation, creating a connection of the type, creating
a repository under one, and minting a URL, so a `false` override is a real
kill-switch for new self-service credentials (existing tokens keep working until
revoked or expired; **revoke and list are deliberately never gated**). Admin
provisioning (`yucca-admin-api`, yuctl) bypasses the flag (admin authority).
## Web UI
The `Connections` dashboard page (`packages/web/src/routes/dashboard/connections/`)
lists each connection with its type and usage rollup. It's a thin SvelteKit route:
`+page.ts` loads `listConnections` + `getRepositories` via the generated client, and
`+page.svelte` renders it with `@immich/ui`.
**Restic self-serve is invisible without the flag.** The "New restic backup" button and
all restic actions render only when `data.user.features['connection-restic']` is true,
absent from the DOM otherwise, not merely disabled. The create flow (`CreateResticModal`)
calls `POST /connections/restic` and opens a result modal (`ResticResultModal`) showing the
`rest:` URL, a `restic -r … init` snippet (copy buttons), and a repository-password
reminder. Per-repository access keys are listed/revoked/re-minted in `ManageTokensModal`.
## Where things live
| Concern | Location |
|---|---|
| Type descriptor + billing floor | `packages/common/src/connections.ts` |
| Schema (`connections`, `connectionMetrics`, `resticTokens`) | `packages/yucca-api/src/schema/` |
| Connection + self-serve restic API | `packages/yucca-api/src/{controllers,services}/` |
| Web Connections page + restic modals | `packages/web/src/routes/dashboard/connections/`, `packages/web/src/lib/components/connections/` |
| Billing rollup | `packages/yucca-metrics-worker/` |
| Validity check (michael: L1/L2/introspection) | `packages/michael/internal/revocation/` |
| Introspection endpoint | `packages/yucca-api/src/controllers/introspection.controller.ts` |
| Admin provisioning | `packages/yucca-admin-api/`, `packages/yuctl/` |
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from '@playwright/test';
const login = async (page: import('@playwright/test').Page) => {
await page.goto('http://localhost:36033/');
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByRole('heading', { name: 'Sign-in' })).toBeVisible();
await page.getByPlaceholder('Enter any login').fill('foo');
await page.getByPlaceholder('and password').fill('password');
await page.getByRole('button', { name: 'Sign-in' }).click();
await expect(page.getByRole('heading', { name: 'Authorize' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByText('Backup Health')).toBeVisible();
};
test('connections page lists the default connection and hides restic without the flag', async ({
page,
}) => {
await login(page);
await page.getByRole('link', { name: 'Connections' }).click();
await expect(
page.getByRole('heading', { name: 'Connections' }),
).toBeVisible();
await expect(page.getByText('Immich').first()).toBeVisible();
await expect(
page.getByRole('button', { name: 'New restic backup' }),
).toHaveCount(0);
});
@@ -0,0 +1,22 @@
import { mdiImageMultiple } from '@mdi/js';
export type ConnectionTypeMeta = {
type: string;
label: string;
icon: string;
description: string;
limitation: string;
addable: boolean;
};
export const CONNECTION_TYPES: ConnectionTypeMeta[] = [
{
type: 'immich',
label: 'Immich',
icon: mdiImageMultiple,
description: 'Back up an Immich instance: photos, videos and the database.',
limitation:
'Added automatically when you connect FUTO Backups from the Immich app; not created here.',
addable: false,
},
];
@@ -0,0 +1,25 @@
import { toastManager } from '@immich/ui';
export const handleError = (error: unknown, fallback: string) => {
console.error(error);
let message = fallback;
const data = (error as { data?: unknown } | undefined)?.data;
const raw = typeof data === 'string' ? tryParse(data) : data;
if (raw && typeof raw === 'object' && 'message' in raw) {
const m = (raw as { message?: unknown }).message;
if (typeof m === 'string' && m.length > 0) {
message = m.length > 120 ? m.slice(0, 117) + '...' : m;
}
}
toastManager.danger(message);
};
const tryParse = (value: string): unknown => {
try {
return JSON.parse(value);
} catch {
return value;
}
};
+261
View File
@@ -13,32 +13,211 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: \n"
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "{0} objects"
#~ msgstr "{0} objects"
#: src/routes/dashboard/connections/+page.svelte
msgid "{0} repositories"
msgstr "{0} repositories"
#: src/routes/+page.svelte
#~ msgid "{num, plural, one {# item} other {# items}}"
#~ msgstr "{num, plural, one {# item} other {# items}}"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "1 year"
#~ msgstr "1 year"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "30 days"
#~ msgstr "30 days"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "90 days"
#~ msgstr "90 days"
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "A connection is a client that backs up this account — an Immich instance or a restic repository."
#~ msgstr "A connection is a client that backs up this account — an Immich instance or a restic repository."
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "A label for this repository."
#~ msgstr "A label for this repository."
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Access key"
#~ msgstr "Access key"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Access key lifetime"
#~ msgstr "Access key lifetime"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Access key revoked"
#~ msgstr "Access key revoked"
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "Access keys"
#~ msgstr "Access keys"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Access keys — {0}"
#~ msgstr "Access keys — {0}"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Access keys: {0}"
#~ msgstr "Access keys: {0}"
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "Add {0} connection"
#~ msgstr "Add {0} connection"
#: src/lib/components/connections/NewConnectionModal.svelte
#~ msgid "Automatic"
#~ msgstr "Automatic"
#: src/routes/dashboard/connections/+page.svelte
msgid "billed"
msgstr "billed"
#: src/lib/components/connections/NewConnectionModal.svelte
#~ msgid "Cancel"
#~ msgstr "Cancel"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Choose a strong repository password"
#~ msgstr "Choose a strong repository password"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Close"
#~ msgstr "Close"
#: src/routes/dashboard/connections/+page.svelte
#: src/routes/dashboard/connections/+page.svelte
msgid "Connections"
msgstr "Connections"
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "Connections are the sources of data that back up to us — right now, that's mostly Immich."
#~ msgstr "Connections are the sources of data that back up to us — right now, that's mostly Immich."
#: src/routes/dashboard/connections/+page.svelte
msgid "Connections are the sources of data that back up to us. Right now, that's mostly Immich."
msgstr "Connections are the sources of data that back up to us. Right now, that's mostly Immich."
#: src/routes/login/invite/+page.svelte
msgid "Continue"
msgstr "Continue"
#: src/lib/components/connections/ResticResultModal.svelte
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Copied"
#~ msgstr "Copied"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Copied to clipboard"
#~ msgstr "Copied to clipboard"
#: src/lib/components/connections/ResticResultModal.svelte
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Copy"
#~ msgstr "Copy"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Copy command"
#~ msgstr "Copy command"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Copy URL"
#~ msgstr "Copy URL"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Couldn't copy — select the text manually"
#~ msgstr "Couldn't copy — select the text manually"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Couldn't copy. Select the text manually"
#~ msgstr "Couldn't copy. Select the text manually"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Create"
#~ msgstr "Create"
#: src/routes/dashboard/backups/+page.svelte
#~ msgid "Create new backup"
#~ msgstr "Create new backup"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Created {0} · expires {1}"
#~ msgstr "Created {0} · expires {1}"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Done"
#~ msgstr "Done"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "e.g. laptop"
#~ msgstr "e.g. laptop"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "e.g. my laptop"
#~ msgstr "e.g. my laptop"
#: src/routes/login/invite/+page.svelte
msgid "Enter an invite code to continue."
msgstr "Enter an invite code to continue."
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Expired"
#~ msgstr "Expired"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Failed to create restic backup"
#~ msgstr "Failed to create restic backup"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Failed to load access keys"
#~ msgstr "Failed to load access keys"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Failed to mint a new access key"
#~ msgstr "Failed to mint a new access key"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Failed to revoke access key"
#~ msgstr "Failed to revoke access key"
#: src/routes/+page.svelte
#~ msgid "From the {0}: {1}"
#~ msgstr "From the {0}: {1}"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "How long the URL stays valid (max 1 year). Revoke the key any time to end access sooner."
#~ msgstr "How long the URL stays valid (max 1 year). Revoke the key any time to end access sooner."
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "How long the URL stays valid, e.g. 90d. Blank uses the default."
#~ msgstr "How long the URL stays valid, e.g. 90d. Blank uses the default."
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "How long the URL stays valid. \"No expiry\" relies on revoking the key instead."
#~ msgstr "How long the URL stays valid. \"No expiry\" relies on revoking the key instead."
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Initialize the repository"
#~ msgstr "Initialize the repository"
#: src/routes/login/invite/+page.svelte
#: src/routes/login/invite/+page.svelte
#: src/routes/login/invite/+page.svelte
msgid "Invite code"
msgstr "Invite code"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Key label"
#~ msgstr "Key label"
#: src/routes/+page.svelte
#: src/routes/+page.svelte
msgid "Login"
@@ -48,10 +227,92 @@ msgstr "Login"
msgid "Logout"
msgstr "Logout"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Name"
#~ msgstr "Name"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "New access key"
#~ msgstr "New access key"
#: src/lib/components/connections/NewConnectionModal.svelte
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "New connection"
#~ msgstr "New connection"
#: src/lib/components/connections/CreateResticModal.svelte
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "New restic backup"
#~ msgstr "New restic backup"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "New restic repository"
#~ msgstr "New restic repository"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "No access keys yet."
#~ msgstr "No access keys yet."
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "No connections yet."
#~ msgstr "No connections yet."
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "No expiry"
#~ msgstr "No expiry"
#: src/lib/components/connections/NewConnectionModal.svelte
#~ msgid "Pick what you want to back up. Each connection type works a little differently."
#~ msgstr "Pick what you want to back up. Each connection type works a little differently."
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Prevent this repository from being deleted or overwritten."
#~ msgstr "Prevent this repository from being deleted or overwritten."
#: src/routes/+page.svelte
#~ msgid "Purchase Immich"
#~ msgstr "Purchase Immich"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Repository"
#~ msgstr "Repository"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Repository URL"
#~ msgstr "Repository URL"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Restic backup ready"
#~ msgstr "Restic backup ready"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "restic will ask you to set a password when you initialize. Keep it safe — it encrypts your backups and cannot be recovered."
#~ msgstr "restic will ask you to set a password when you initialize. Keep it safe — it encrypts your backups and cannot be recovered."
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "restic will ask you to set a password when you initialize. Keep it safe: it encrypts your backups and cannot be recovered."
#~ msgstr "restic will ask you to set a password when you initialize. Keep it safe: it encrypts your backups and cannot be recovered."
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Revoke"
#~ msgstr "Revoke"
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Revoked"
#~ msgstr "Revoked"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Shown in the access-key list."
#~ msgstr "Shown in the access-key list."
#: src/lib/components/connections/NewConnectionModal.svelte
#~ msgid "This connection type can't be added from here — see the note above."
#~ msgstr "This connection type can't be added from here — see the note above."
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Write-once (WORM)"
#~ msgstr "Write-once (WORM)"
#: src/routes/login/invite/+page.svelte
msgid "Your email isn't part of the beta yet."
msgstr "Your email isn't part of the beta yet."
+261
View File
@@ -13,32 +13,211 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: \n"
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "{0} objects"
#~ msgstr ""
#: src/routes/dashboard/connections/+page.svelte
msgid "{0} repositories"
msgstr ""
#: src/routes/+page.svelte
#~ msgid "{num, plural, one {# item} other {# items}}"
#~ msgstr "{num, plural, one {# hello} other {# hellos}}"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "1 year"
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "30 days"
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "90 days"
#~ msgstr ""
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "A connection is a client that backs up this account — an Immich instance or a restic repository."
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "A label for this repository."
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Access key"
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Access key lifetime"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Access key revoked"
#~ msgstr ""
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "Access keys"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Access keys — {0}"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Access keys: {0}"
#~ msgstr ""
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "Add {0} connection"
#~ msgstr ""
#: src/lib/components/connections/NewConnectionModal.svelte
#~ msgid "Automatic"
#~ msgstr ""
#: src/routes/dashboard/connections/+page.svelte
msgid "billed"
msgstr ""
#: src/lib/components/connections/NewConnectionModal.svelte
#~ msgid "Cancel"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Choose a strong repository password"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Close"
#~ msgstr ""
#: src/routes/dashboard/connections/+page.svelte
#: src/routes/dashboard/connections/+page.svelte
msgid "Connections"
msgstr ""
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "Connections are the sources of data that back up to us — right now, that's mostly Immich."
#~ msgstr ""
#: src/routes/dashboard/connections/+page.svelte
msgid "Connections are the sources of data that back up to us. Right now, that's mostly Immich."
msgstr ""
#: src/routes/login/invite/+page.svelte
msgid "Continue"
msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Copied"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Copied to clipboard"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Copy"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Copy command"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Copy URL"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Couldn't copy — select the text manually"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Couldn't copy. Select the text manually"
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Create"
#~ msgstr ""
#: src/routes/dashboard/backups/+page.svelte
#~ msgid "Create new backup"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Created {0} · expires {1}"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Done"
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "e.g. laptop"
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "e.g. my laptop"
#~ msgstr ""
#: src/routes/login/invite/+page.svelte
msgid "Enter an invite code to continue."
msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Expired"
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Failed to create restic backup"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Failed to load access keys"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Failed to mint a new access key"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Failed to revoke access key"
#~ msgstr ""
#: src/routes/+page.svelte
#~ msgid "From the {0}: {1}"
#~ msgstr "{0}! {1}?"
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "How long the URL stays valid (max 1 year). Revoke the key any time to end access sooner."
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "How long the URL stays valid, e.g. 90d. Blank uses the default."
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "How long the URL stays valid. \"No expiry\" relies on revoking the key instead."
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Initialize the repository"
#~ msgstr ""
#: src/routes/login/invite/+page.svelte
#: src/routes/login/invite/+page.svelte
#: src/routes/login/invite/+page.svelte
msgid "Invite code"
msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Key label"
#~ msgstr ""
#: src/routes/+page.svelte
#: src/routes/+page.svelte
msgid "Login"
@@ -48,10 +227,92 @@ msgstr ""
msgid "Logout"
msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Name"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "New access key"
#~ msgstr ""
#: src/lib/components/connections/NewConnectionModal.svelte
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "New connection"
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "New restic backup"
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "New restic repository"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "No access keys yet."
#~ msgstr ""
#: src/routes/dashboard/connections/+page.svelte
#~ msgid "No connections yet."
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "No expiry"
#~ msgstr ""
#: src/lib/components/connections/NewConnectionModal.svelte
#~ msgid "Pick what you want to back up. Each connection type works a little differently."
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Prevent this repository from being deleted or overwritten."
#~ msgstr ""
#: src/routes/+page.svelte
#~ msgid "Purchase Immich"
#~ msgstr "Hello Immich"
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Repository"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Repository URL"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "Restic backup ready"
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "restic will ask you to set a password when you initialize. Keep it safe — it encrypts your backups and cannot be recovered."
#~ msgstr ""
#: src/lib/components/connections/ResticResultModal.svelte
#~ msgid "restic will ask you to set a password when you initialize. Keep it safe: it encrypts your backups and cannot be recovered."
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Revoke"
#~ msgstr ""
#: src/lib/components/connections/ManageTokensModal.svelte
#~ msgid "Revoked"
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Shown in the access-key list."
#~ msgstr ""
#: src/lib/components/connections/NewConnectionModal.svelte
#~ msgid "This connection type can't be added from here — see the note above."
#~ msgstr ""
#: src/lib/components/connections/CreateResticModal.svelte
#~ msgid "Write-once (WORM)"
#~ msgstr ""
#: src/routes/login/invite/+page.svelte
msgid "Your email isn't part of the beta yet."
msgstr ""
@@ -10,7 +10,7 @@
HStack,
NavbarItem,
} from "@immich/ui";
import { mdiBackupRestore, mdiViewDashboard } from "@mdi/js";
import { mdiBackupRestore, mdiConnection, mdiViewDashboard } from "@mdi/js";
import {
setProvider,
yuccaApiProvider,
@@ -53,6 +53,12 @@
icon={mdiBackupRestore}
active={page.url.pathname === "/dashboard/backups"}
/>
<NavbarItem
title="Connections"
href="/dashboard/connections"
icon={mdiConnection}
active={page.url.pathname === "/dashboard/connections"}
/>
</div>
</AppShellSidebar>
@@ -0,0 +1,77 @@
<script lang="ts">
import { CONNECTION_TYPES } from "$lib/components/connections/connection-types";
import { type ConnectionDto } from "@futo-org/backups-api-client";
import {
Badge,
Card,
CardHeader,
CardTitle,
FormatBytes,
Heading,
HStack,
Icon,
Stack,
Text,
} from "@immich/ui";
import { t } from "svelte-i18n-lingui";
const { data } = $props();
const connectionsByType = $derived.by(() => {
const map = new Map<string, ConnectionDto[]>();
for (const connection of data.connections) {
const list = map.get(connection.type) ?? [];
list.push(connection);
map.set(connection.type, list);
}
return map;
});
const typeColor = (type: string) =>
type === "immich" ? "primary" : "success";
</script>
<svelte:head><title>{$t`Connections`} &middot; FUTO Backups</title></svelte:head
>
<Stack gap={5}>
<Heading tag="h1" size="small">{$t`Connections`}</Heading>
<Text color="muted"
>{$t`Connections are the sources of data that back up to us. Right now, that's mostly Immich.`}</Text
>
{#each CONNECTION_TYPES as meta (meta.type)}
{@const connections = connectionsByType.get(meta.type) ?? []}
<Stack gap={2}>
<HStack gap={2}>
<Icon icon={meta.icon} />
<Heading size="tiny">{meta.label}</Heading>
</HStack>
{#if connections.length === 0}
<Text size="small" color="muted">{meta.limitation}</Text>
{/if}
{#each connections as connection (connection.id)}
<Card>
<CardHeader>
<HStack class="justify-between">
<HStack gap={2}>
<CardTitle>{connection.name}</CardTitle>
<Badge color={typeColor(connection.type)}
>{connection.type}</Badge
>
</HStack>
<Text size="small" color="muted">
{$t`${connection.repositoryCount} repositories`} ·
<FormatBytes bytes={connection.billableBytes} />
{$t`billed`}
</Text>
</HStack>
</CardHeader>
</Card>
{/each}
</Stack>
{/each}
</Stack>
@@ -0,0 +1,13 @@
import type { PageLoad } from './$types';
import { getRepositories, listConnections } from '@futo-org/backups-api-client';
export const load: PageLoad = async ({ fetch }) => {
const [connections, repositories] = await Promise.all([
listConnections({ fetch }),
getRepositories({ fetch }),
]);
return {
connections: connections.connections,
repositories: repositories.repositories,
};
};