mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat: 'standalone' connection type (#534)
This commit is contained in:
+10
-8
@@ -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
|
||||
|
||||
@@ -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`} · FUTO Backups</title></svelte:head
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user