diff --git a/docs/connections.md b/docs/connections.md index 925a8e8e..4b0c0495 100644 --- a/docs/connections.md +++ b/docs/connections.md @@ -28,6 +28,7 @@ per-user/instance state is data. The descriptor lives in | Type | Metering tiers | Reports activity | Min object size | Revocable | Self-serve flag | |---|---|---|---|---|---| | `immich` | storage, transfer, activity | yes | 0 | no | none (always on) | +| `standalone` | storage, transfer, activity | yes | 1 MiB | no | none (always on) | | `restic` | storage, transfer | no | 1 MiB | yes | `connection-restic` | | `s3` *(future)* | storage | no | 1 MiB | yes |, | @@ -35,11 +36,11 @@ per-user/instance state is data. The descriptor lives in 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 | ✅ | ❌ | ❌ | +| Tier | Source | immich | standalone | restic | s3 | +|---|---|---|---|---|---| +| **Storage** (bytes, objects) → **billed** | RadosGW | ✅ | ✅ | ✅ | ✅ | +| Transfer | michael | ✅ | ✅ | ✅ | ❌ | +| Activity (backup start/end) | client | ✅ | ✅ | ❌ | ❌ | ## Billing rollup @@ -55,9 +56,10 @@ returns the rollup per connection. 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 +immich is exempt (floor 0 → billed at raw size). Every other type, standalone +included, bills 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 diff --git a/packages/common/src/connections.ts b/packages/common/src/connections.ts index 384ac83c..c89241e6 100644 --- a/packages/common/src/connections.ts +++ b/packages/common/src/connections.ts @@ -1,4 +1,4 @@ -export const ConnectionTypes = ['immich', 'restic'] as const; +export const ConnectionTypes = ['immich', 'standalone', 'restic'] as const; export type ConnectionType = (typeof ConnectionTypes)[number]; @@ -24,6 +24,7 @@ const MIB = 1 << 20; export const ConnectionTypeInfos = { immich: { meters: ['storage', 'transfer', 'activity'], reportsActivity: true, minObjectSizeBytes: 0 }, + standalone: { meters: ['storage', 'transfer', 'activity'], reportsActivity: true, minObjectSizeBytes: MIB }, restic: { meters: ['storage', 'transfer'], reportsActivity: false, minObjectSizeBytes: MIB }, } as const satisfies Record; diff --git a/packages/web/src/lib/components/connections/connection-types.ts b/packages/web/src/lib/components/connections/connection-types.ts index febc8a3f..a99812c6 100644 --- a/packages/web/src/lib/components/connections/connection-types.ts +++ b/packages/web/src/lib/components/connections/connection-types.ts @@ -1,4 +1,4 @@ -import { mdiImageMultiple } from '@mdi/js'; +import { mdiImageMultiple, mdiServer } from '@mdi/js'; export type ConnectionTypeMeta = { type: string; @@ -19,4 +19,14 @@ export const CONNECTION_TYPES: ConnectionTypeMeta[] = [ 'Added automatically when you connect FUTO Backups from the Immich app; not created here.', addable: false, }, + { + type: 'standalone', + label: 'Standalone', + icon: mdiServer, + description: + 'Back up files from a machine running the standalone FUTO Backups app.', + limitation: + 'Added automatically when you connect FUTO Backups from the standalone app; not created here.', + addable: false, + }, ]; diff --git a/packages/web/src/routes/dashboard/connections/+page.svelte b/packages/web/src/routes/dashboard/connections/+page.svelte index 2a3032d5..f5ca554f 100644 --- a/packages/web/src/routes/dashboard/connections/+page.svelte +++ b/packages/web/src/routes/dashboard/connections/+page.svelte @@ -27,8 +27,12 @@ return map; }); - const typeColor = (type: string) => - type === "immich" ? "primary" : "success"; + const TYPE_COLORS: Record = { + immich: "primary", + standalone: "secondary", + }; + + const typeColor = (type: string) => TYPE_COLORS[type] ?? "success"; {$t`Connections`} · FUTO Backups { ); }); + it('binds a standalone instance for everyone (no flag needed)', async () => { + mocks.connection.getByUserTypeName.mockResolvedValue(void 0); + mocks.connection.create.mockResolvedValue({ id: 'standalone-nas' } as never); + + await expect(sut.oidcDeviceFlow(jest.fn(), 'standalone', 'nas')).resolves.toEqual({ accessToken }); + + expect(mocks.connection.create).toHaveBeenCalledWith({ + userId: mockUser.id, + type: 'standalone', + name: 'nas', + }); + expect(mocks.session.create).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'standalone-nas', kind: 'device' }), + ); + }); + it('creates a restic connection instance when connection-restic is on', async () => { mocks.user.getFeatureOverrides.mockResolvedValue([{ flag: 'connection-restic', value: true }]); mocks.connection.getByUserTypeName.mockResolvedValue(void 0); diff --git a/packages/yucca-api/test/connection.integration-spec.ts b/packages/yucca-api/test/connection.integration-spec.ts index 84be0183..075a8658 100644 --- a/packages/yucca-api/test/connection.integration-spec.ts +++ b/packages/yucca-api/test/connection.integration-spec.ts @@ -68,6 +68,16 @@ describe('ConnectionController (e2e)', () => { expect(body.connection).toMatchObject({ type: 'immich', name: 'second-server', repositoryCount: 0 }); }); + it('lets anyone create a standalone connection (no flag)', async () => { + const { body } = await request(app.getHttpServer()) + .post('/api/connections') + .set('Cookie', cookie()) + .send({ type: 'standalone', name: 'nas' }) + .expect(201); + + expect(body.connection).toMatchObject({ type: 'standalone', name: 'nas', repositoryCount: 0 }); + }); + it('403s creating a restic connection without connection-restic', async () => { await request(app.getHttpServer()) .post('/api/connections') diff --git a/packages/yucca-metrics-worker/src/services/billing.spec.ts b/packages/yucca-metrics-worker/src/services/billing.spec.ts index 541512ae..03ab458f 100644 --- a/packages/yucca-metrics-worker/src/services/billing.spec.ts +++ b/packages/yucca-metrics-worker/src/services/billing.spec.ts @@ -8,6 +8,11 @@ describe('billableBytes (storage-tier billing floor)', () => { expect(billableBytes('immich', 1024, 500)).toBe(1024); }); + it('floors standalone like restic', () => { + expect(billableBytes('standalone', 1024, 500)).toBe(500 * MIB); + expect(billableBytes('standalone', 800 * MIB, 500)).toBe(800 * MIB); + }); + it('floors small restic objects at 1 MiB each', () => { expect(billableBytes('restic', 1024, 100)).toBe(100 * MIB); }); diff --git a/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts b/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts index 59f63530..d4b3dcf1 100644 --- a/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts +++ b/packages/yucca-sdk/orchestration-api/src/services/auth.service.ts @@ -23,16 +23,22 @@ export class AuthService { readonly repository: RepositoryRepository, ) {} + private connectionType(): string { + return this.moduleConfig.get().immichIntegration ? 'immich' : 'standalone'; + } + private connectionName(): string { + const fallback = hostname() || this.connectionType(); const external = this.moduleConfig.get().externalBaseUrl; if (external) { try { return new URL(external).host; } catch { - return hostname() || 'immich'; + return fallback; } } - return hostname() || 'immich'; + + return fallback; } private async adoptOwnRepositories(endpoint: string | undefined, accessToken: string): Promise { @@ -114,7 +120,7 @@ export class AuthService { const endpoint = await yuccaWellKnown.getBaseUrl(); const url = new URL('/api/auth/oidc/device', endpoint); - url.searchParams.set('connection_type', 'immich'); + url.searchParams.set('connection_type', this.connectionType()); url.searchParams.set('connection_name', this.connectionName()); const events: EventSourceClient = createEventSource({