feat: 'standalone' connection type (#534)

This commit is contained in:
Paul Makles
2026-08-24 11:30:36 +01:00
committed by GitHub
parent f91b803356
commit 8832cda5c0
11 changed files with 75 additions and 19 deletions
+10 -8
View File
@@ -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
+2 -1
View File
@@ -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<ConnectionType, ConnectionTypeInfo>;
@@ -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,
},
];
@@ -27,8 +27,12 @@
return map;
});
const typeColor = (type: string) =>
type === "immich" ? "primary" : "success";
const TYPE_COLORS: Record<string, "primary" | "secondary" | "success"> = {
immich: "primary",
standalone: "secondary",
};
const typeColor = (type: string) => TYPE_COLORS[type] ?? "success";
</script>
<svelte:head><title>{$t`Connections`} &middot; FUTO Backups</title></svelte:head
+3 -1
View File
@@ -97,7 +97,7 @@
"name": "connection_type",
"required": false,
"in": "query",
"description": "immich | restic",
"description": "immich | standalone | restic",
"schema": {
"type": "string"
}
@@ -789,6 +789,7 @@
"type": "string",
"enum": [
"immich",
"standalone",
"restic"
]
},
@@ -850,6 +851,7 @@
"type": "string",
"enum": [
"immich",
"standalone",
"restic"
]
},
@@ -58,7 +58,7 @@ export type MetaResponseDto = {
};
export type ConnectionDto = {
id: string;
"type": "immich" | "restic";
"type": "immich" | "standalone" | "restic";
name: string;
createdAt: string;
lastSeenAt?: string | null;
@@ -74,7 +74,7 @@ export type ConnectionListResponseDto = {
connections: ConnectionDto[];
};
export type ConnectionCreateRequestDto = {
"type": "immich" | "restic";
"type": "immich" | "standalone" | "restic";
name: string;
};
export type ConnectionResponseDto = {
@@ -90,7 +90,7 @@ export class AuthController {
}
@Sse('/oidc/device')
@ApiQuery({ name: 'connection_type', type: String, required: false, description: 'immich | restic' })
@ApiQuery({ name: 'connection_type', type: String, required: false, description: 'immich | standalone | restic' })
@ApiQuery({ name: 'connection_name', type: String, required: false, description: 'Instance name, e.g. a hostname' })
oidcDeviceFlow(
@Query('connection_type') connectionType?: string,
@@ -329,6 +329,22 @@ describe(AuthService.name, () => {
);
});
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);
@@ -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')
@@ -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);
});
@@ -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<void> {
@@ -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({