diff --git a/README.md b/README.md index 6ac834e..9240b72 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ Details, one-time machine setup, and caveats: [dev/README.md](dev/README.md) +For UI-only work on macOS or without the local database/backend stack, use the +fixture mode documented in [dev/README.md](dev/README.md#fixture-ui-mode). + ### secrets - Required: diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index ab564cf..5d17807 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -5,6 +5,11 @@ CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgres://postgres:my # The canonical URL. ORIGIN="http://localhost:5173" +# Branding +# Use "ultramarine" for standalone Ultramarine Server deployments. +# This is read at build time. Defaults to "stack" when unset. +PUBLIC_DASHBOARD_BRAND="stack" + # Better Auth # For production use 32+ characters generated with high entropy # https://www.better-auth.com/docs/installation diff --git a/apps/dashboard/drizzle/0028_lame_james_howlett.sql b/apps/dashboard/drizzle/0028_lame_james_howlett.sql new file mode 100644 index 0000000..ec1f336 --- /dev/null +++ b/apps/dashboard/drizzle/0028_lame_james_howlett.sql @@ -0,0 +1,29 @@ +CREATE TYPE "public"."managed_host_connection_mode" AS ENUM('direct_http', 'websocket', 'vsock_gateway', 'offline');--> statement-breakpoint +CREATE TYPE "public"."managed_host_connection_state" AS ENUM('online', 'offline', 'unknown');--> statement-breakpoint +CREATE TYPE "public"."managed_host_kind" AS ENUM('stack_vps', 'external', 'local');--> statement-breakpoint +CREATE TABLE "managed_hosts" ( + "id" text PRIMARY KEY NOT NULL, + "display_name" text NOT NULL, + "owner_project_id" text NOT NULL, + "host_kind" "managed_host_kind" DEFAULT 'external' NOT NULL, + "linked_vm_id" text, + "connection_mode" "managed_host_connection_mode" DEFAULT 'direct_http' NOT NULL, + "connection_state" "managed_host_connection_state" DEFAULT 'unknown' NOT NULL, + "agent_url" text, + "bearer_token" text, + "last_seen_at" bigint, + "agent_version" text, + "hostname" text, + "os" text, + "arch" text, + "capabilities" jsonb, + "last_error" text, + "created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint NOT NULL, + "updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint NOT NULL +); +--> statement-breakpoint +ALTER TABLE "managed_hosts" ADD CONSTRAINT "managed_hosts_owner_project_id_organization_id_fk" FOREIGN KEY ("owner_project_id") REFERENCES "public"."organization"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "managed_hosts" ADD CONSTRAINT "managed_hosts_linked_vm_id_vms_id_fk" FOREIGN KEY ("linked_vm_id") REFERENCES "public"."vms"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "managed_hosts_owner_project_id_index" ON "managed_hosts" USING btree ("owner_project_id");--> statement-breakpoint +CREATE INDEX "managed_hosts_linked_vm_id_index" ON "managed_hosts" USING btree ("linked_vm_id");--> statement-breakpoint +CREATE INDEX "managed_hosts_connection_state_index" ON "managed_hosts" USING btree ("connection_state"); \ No newline at end of file diff --git a/apps/dashboard/drizzle/meta/0028_snapshot.json b/apps/dashboard/drizzle/meta/0028_snapshot.json new file mode 100644 index 0000000..7b4546c --- /dev/null +++ b/apps/dashboard/drizzle/meta/0028_snapshot.json @@ -0,0 +1,2790 @@ +{ + "id": "3ef743b4-a356-4a73-a24a-bcbc0fc7d9ee", + "prevId": "f6ddb885-2f3b-4eeb-8a7f-b5176fe15d74", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "api_tokens_user_id_index": { + "name": "api_tokens_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.base_images": { + "name": "base_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'bg-gray-600'" + }, + "is_official": { + "name": "is_official", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "logo_svg": { + "name": "logo_svg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accent_color": { + "name": "accent_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#6b7280'" + }, + "image_type": { + "name": "image_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'qcow2'" + }, + "secure_boot": { + "name": "secure_boot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "isa": { + "name": "isa", + "type": "vm_isa", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_meters": { + "name": "billing_meters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "billing_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "units": { + "name": "units", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_metered_at": { + "name": "last_metered_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "billing_meters_resource_index": { + "name": "billing_meters_resource_index", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_meters_active_last_metered_at_index": { + "name": "billing_meters_active_last_metered_at_index", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_metered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_meters_project_active_index": { + "name": "billing_meters_project_active_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_usage_events": { + "name": "billing_usage_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "billing_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "period_end": { + "name": "period_end", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "billing_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "synced_at": { + "name": "synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "billing_usage_events_idempotency_key_index": { + "name": "billing_usage_events_idempotency_key_index", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_usage_events_project_created_at_index": { + "name": "billing_usage_events_project_created_at_index", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_usage_events_sync_status_created_at_index": { + "name": "billing_usage_events_sync_status_created_at_index", + "columns": [ + { + "expression": "sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_usage_events_resource_index": { + "name": "billing_usage_events_resource_index", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ip_assignments": { + "name": "ip_assignments", + "schema": "", + "columns": { + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": true + }, + "ip_block_id": { + "name": "ip_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "associated_vm_id": { + "name": "associated_vm_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ip_assignments_ip_block_id_index": { + "name": "ip_assignments_ip_block_id_index", + "columns": [ + { + "expression": "ip_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ip_assignments_associated_vm_id_index": { + "name": "ip_assignments_associated_vm_id_index", + "columns": [ + { + "expression": "associated_vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ip_assignments_ip_block_id_ip_blocks_id_fk": { + "name": "ip_assignments_ip_block_id_ip_blocks_id_fk", + "tableFrom": "ip_assignments", + "tableTo": "ip_blocks", + "columnsFrom": [ + "ip_block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ip_assignments_associated_vm_id_vms_id_fk": { + "name": "ip_assignments_associated_vm_id_vms_id_fk", + "tableFrom": "ip_assignments", + "tableTo": "vms", + "columnsFrom": [ + "associated_vm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ip_blocks": { + "name": "ip_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ip_block": { + "name": "ip_block", + "type": "cidr", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ipam_allocations": { + "name": "ipam_allocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ipam_prefix_id": { + "name": "ipam_prefix_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "associated_vm_id": { + "name": "associated_vm_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "family": { + "name": "family", + "type": "ip_family", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "cidr", + "primaryKey": false, + "notNull": false + }, + "prefix_length": { + "name": "prefix_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mac_address": { + "name": "mac_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "ipam_allocations_ipam_prefix_id_index": { + "name": "ipam_allocations_ipam_prefix_id_index", + "columns": [ + { + "expression": "ipam_prefix_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_allocations_associated_vm_id_index": { + "name": "ipam_allocations_associated_vm_id_index", + "columns": [ + { + "expression": "associated_vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_allocations_vm_family_index": { + "name": "ipam_allocations_vm_family_index", + "columns": [ + { + "expression": "associated_vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "family", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_allocations_address_index": { + "name": "ipam_allocations_address_index", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_allocations_prefix_index": { + "name": "ipam_allocations_prefix_index", + "columns": [ + { + "expression": "prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ipam_allocations_ipam_prefix_id_ipam_prefixes_id_fk": { + "name": "ipam_allocations_ipam_prefix_id_ipam_prefixes_id_fk", + "tableFrom": "ipam_allocations", + "tableTo": "ipam_prefixes", + "columnsFrom": [ + "ipam_prefix_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ipam_allocations_associated_vm_id_vms_id_fk": { + "name": "ipam_allocations_associated_vm_id_vms_id_fk", + "tableFrom": "ipam_allocations", + "tableTo": "vms", + "columnsFrom": [ + "associated_vm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ipam_prefixes": { + "name": "ipam_prefixes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cidr": { + "name": "cidr", + "type": "cidr", + "primaryKey": false, + "notNull": true + }, + "family": { + "name": "family", + "type": "ip_family", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ipv6_use_transit_address": { + "name": "ipv6_use_transit_address", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "whitelist_start": { + "name": "whitelist_start", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "whitelist_end": { + "name": "whitelist_end", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "gateway_address": { + "name": "gateway_address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "ipam_prefixes_cidr_index": { + "name": "ipam_prefixes_cidr_index", + "columns": [ + { + "expression": "cidr", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ipam_prefixes_family_disabled_index": { + "name": "ipam_prefixes_family_disabled_index", + "columns": [ + { + "expression": "family", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "disabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.managed_hosts": { + "name": "managed_hosts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_project_id": { + "name": "owner_project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "host_kind": { + "name": "host_kind", + "type": "managed_host_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'external'" + }, + "linked_vm_id": { + "name": "linked_vm_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connection_mode": { + "name": "connection_mode", + "type": "managed_host_connection_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'direct_http'" + }, + "connection_state": { + "name": "connection_state", + "type": "managed_host_connection_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "agent_url": { + "name": "agent_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bearer_token": { + "name": "bearer_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "arch": { + "name": "arch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "managed_hosts_owner_project_id_index": { + "name": "managed_hosts_owner_project_id_index", + "columns": [ + { + "expression": "owner_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "managed_hosts_linked_vm_id_index": { + "name": "managed_hosts_linked_vm_id_index", + "columns": [ + { + "expression": "linked_vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "managed_hosts_connection_state_index": { + "name": "managed_hosts_connection_state_index", + "columns": [ + { + "expression": "connection_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "managed_hosts_owner_project_id_organization_id_fk": { + "name": "managed_hosts_owner_project_id_organization_id_fk", + "tableFrom": "managed_hosts", + "tableTo": "organization", + "columnsFrom": [ + "owner_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "managed_hosts_linked_vm_id_vms_id_fk": { + "name": "managed_hosts_linked_vm_id_vms_id_fk", + "tableFrom": "managed_hosts", + "tableTo": "vms", + "columnsFrom": [ + "linked_vm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_periods": { + "name": "payment_periods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "vm_id": { + "name": "vm_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate": { + "name": "rate", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "cap": { + "name": "cap", + "type": "numeric", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "payment_periods_vm_id_index": { + "name": "payment_periods_vm_id_index", + "columns": [ + { + "expression": "vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_periods_user_id_index": { + "name": "payment_periods_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_periods_vm_id_vms_id_fk": { + "name": "payment_periods_vm_id_vms_id_fk", + "tableFrom": "payment_periods", + "tableTo": "vms", + "columnsFrom": [ + "vm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_billing_customers": { + "name": "project_billing_customers", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "autumn_customer_id": { + "name": "autumn_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "billing_sync_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "past_due_since": { + "name": "past_due_since", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "project_billing_customers_autumn_customer_id_index": { + "name": "project_billing_customers_autumn_customer_id_index", + "columns": [ + { + "expression": "autumn_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ssh_keys": { + "name": "ssh_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ssh_keys_user_id_index": { + "name": "ssh_keys_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vm_types": { + "name": "vm_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isa": { + "name": "isa", + "type": "vm_isa", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "cores": { + "name": "cores", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ram_capacity": { + "name": "ram_capacity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_amount": { + "name": "storage_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rate": { + "name": "rate", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "cap": { + "name": "cap", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "autumn_feature_id": { + "name": "autumn_feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vms": { + "name": "vms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proxmox_id": { + "name": "proxmox_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "proxmox_node": { + "name": "proxmox_node", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_known_ipv4": { + "name": "last_known_ipv4", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "last_known_ipv6": { + "name": "last_known_ipv6", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "last_known_status": { + "name": "last_known_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_known_uptime": { + "name": "last_known_uptime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_known_at": { + "name": "last_known_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "owner_project_id": { + "name": "owner_project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vm_type_id": { + "name": "vm_type_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creation_date": { + "name": "creation_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "backend": { + "name": "backend", + "type": "vm_backend", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "vm_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "status_error": { + "name": "status_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "vms_owner_project_id_index": { + "name": "vms_owner_project_id_index", + "columns": [ + { + "expression": "owner_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vms_owner_project_active_index": { + "name": "vms_owner_project_active_index", + "columns": [ + { + "expression": "owner_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vms_proxmox_id_index": { + "name": "vms_proxmox_id_index", + "columns": [ + { + "expression": "proxmox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vms_owner_project_id_organization_id_fk": { + "name": "vms_owner_project_id_organization_id_fk", + "tableFrom": "vms", + "tableTo": "organization", + "columnsFrom": [ + "owner_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "vms_vm_type_id_vm_types_id_fk": { + "name": "vms_vm_type_id_vm_types_id_fk", + "tableFrom": "vms", + "tableTo": "vm_types", + "columnsFrom": [ + "vm_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.volumes": { + "name": "volumes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "owner_project_id": { + "name": "owner_project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "associated_vm_id": { + "name": "associated_vm_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "volumes_owner_project_id_index": { + "name": "volumes_owner_project_id_index", + "columns": [ + { + "expression": "owner_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "volumes_associated_vm_id_index": { + "name": "volumes_associated_vm_id_index", + "columns": [ + { + "expression": "associated_vm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "volumes_owner_project_id_organization_id_fk": { + "name": "volumes_owner_project_id_organization_id_fk", + "tableFrom": "volumes", + "tableTo": "organization", + "columnsFrom": [ + "owner_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "volumes_associated_vm_id_vms_id_fk": { + "name": "volumes_associated_vm_id_vms_id_fk", + "tableFrom": "volumes", + "tableTo": "vms", + "columnsFrom": [ + "associated_vm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_exempt": { + "name": "billing_exempt", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_slug_uidx": { + "name": "organization_slug_uidx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "twoFactor_secret_idx": { + "name": "twoFactor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "twoFactor_userId_idx": { + "name": "twoFactor_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_exempt": { + "name": "billing_exempt", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.billing_resource_type": { + "name": "billing_resource_type", + "schema": "public", + "values": [ + "vm", + "volume" + ] + }, + "public.billing_sync_status": { + "name": "billing_sync_status", + "schema": "public", + "values": [ + "pending", + "synced", + "failed", + "abandoned" + ] + }, + "public.ip_family": { + "name": "ip_family", + "schema": "public", + "values": [ + "ipv4", + "ipv6" + ] + }, + "public.managed_host_connection_mode": { + "name": "managed_host_connection_mode", + "schema": "public", + "values": [ + "direct_http", + "websocket", + "vsock_gateway", + "offline" + ] + }, + "public.managed_host_connection_state": { + "name": "managed_host_connection_state", + "schema": "public", + "values": [ + "online", + "offline", + "unknown" + ] + }, + "public.managed_host_kind": { + "name": "managed_host_kind", + "schema": "public", + "values": [ + "stack_vps", + "external", + "local" + ] + }, + "public.vm_backend": { + "name": "vm_backend", + "schema": "public", + "values": [ + "proxmox" + ] + }, + "public.vm_isa": { + "name": "vm_isa", + "schema": "public", + "values": [ + "x86", + "arm", + "risc-v" + ] + }, + "public.vm_status": { + "name": "vm_status", + "schema": "public", + "values": [ + "provisioning", + "ready", + "error", + "deleting" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/dashboard/drizzle/meta/_journal.json b/apps/dashboard/drizzle/meta/_journal.json index 965ec8a..66de60c 100644 --- a/apps/dashboard/drizzle/meta/_journal.json +++ b/apps/dashboard/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1784357065417, "tag": "0027_adorable_bedlam", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1784413794152, + "tag": "0028_lame_james_howlett", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 41a9451..acca47f 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -7,8 +7,10 @@ "dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 vite dev", "build": "vite build", "preview": "vite preview", - "test:accessibility": "playwright test", - "test:accessibility:ci": "playwright install --with-deps chrome && playwright test", + "test:accessibility": "node tests/run-accessibility-brands.mjs", + "test:accessibility:stack": "playwright test", + "test:accessibility:ultramarine": "PUBLIC_DASHBOARD_BRAND=ultramarine playwright test", + "test:accessibility:ci": "playwright install --with-deps chrome && pnpm run test:accessibility", "cf:dev": "pnpm run build && wrangler dev -c wrangler.local.jsonc --local-protocol http", "cf:dev:remote": "pnpm run build && wrangler dev --remote", "cf:deploy": "wrangler deploy --env=\"\"", diff --git a/apps/dashboard/src/app.d.ts b/apps/dashboard/src/app.d.ts index ca07deb..a88df8e 100644 --- a/apps/dashboard/src/app.d.ts +++ b/apps/dashboard/src/app.d.ts @@ -24,6 +24,7 @@ declare global { ctx: ExecutionContext; env: { ORIGIN: string; + PUBLIC_DASHBOARD_BRAND?: string; BETTER_AUTH_SECRET: string; VYOS_API_URL?: string; VYOS_API_KEY?: string; @@ -73,6 +74,7 @@ declare global { colocation: boolean; firewall: boolean; images: boolean; + managedHosts: boolean; volumes: boolean; }; } diff --git a/apps/dashboard/src/lib/branding.ts b/apps/dashboard/src/lib/branding.ts new file mode 100644 index 0000000..54a3b22 --- /dev/null +++ b/apps/dashboard/src/lib/branding.ts @@ -0,0 +1,28 @@ +export type DashboardBrandId = 'stack' | 'ultramarine'; + +const requestedBrand = import.meta.env.PUBLIC_DASHBOARD_BRAND?.toLowerCase(); +export const dashboardBrandId: DashboardBrandId = + requestedBrand === 'ultramarine' ? 'ultramarine' : 'stack'; + +export const dashboardBrand = { + id: dashboardBrandId, + name: dashboardBrandId === 'ultramarine' ? 'Ultramarine Server' : 'Stack', + title: dashboardBrandId === 'ultramarine' ? 'Ultramarine Server' : 'Stack', + logo: dashboardBrandId === 'ultramarine' ? '/ultramarine-logo.svg' : '/logo.svg', + favicon: dashboardBrandId === 'ultramarine' ? '/ultramarine-favicon.ico' : '/favicon.ico', + favicon16: + dashboardBrandId === 'ultramarine' ? '/ultramarine-favicon-16x16.png' : '/favicon-16x16.png', + favicon32: + dashboardBrandId === 'ultramarine' ? '/ultramarine-favicon-32x32.png' : '/favicon-32x32.png', + appleTouchIcon: + dashboardBrandId === 'ultramarine' + ? '/ultramarine-apple-touch-icon.png' + : '/apple-touch-icon.png', + isStandalone: dashboardBrandId === 'ultramarine', + defaultProjectPath: dashboardBrandId === 'ultramarine' ? 'hosts' : 'servers', + plausibleDomain: dashboardBrandId === 'ultramarine' ? null : 'dash.fyrastack.com' +} as const; + +export function pageTitle(title: string) { + return `${title} / ${dashboardBrand.title}`; +} diff --git a/apps/dashboard/src/lib/components/dialogs/totp-onboarding-dialog.svelte b/apps/dashboard/src/lib/components/dialogs/totp-onboarding-dialog.svelte index d749fb8..b022fd8 100644 --- a/apps/dashboard/src/lib/components/dialogs/totp-onboarding-dialog.svelte +++ b/apps/dashboard/src/lib/components/dialogs/totp-onboarding-dialog.svelte @@ -8,6 +8,7 @@ import Check from '~icons/lucide/check'; import Copy from '~icons/nucleo/copy'; import ShieldCheck from '~icons/nucleo/shield-check'; + import { dashboardBrand } from '$lib/branding'; type Props = { open?: boolean; @@ -62,7 +63,7 @@ verifyError = ''; const { data, error } = await authClient.twoFactor.enable({ password: setupPassword, - issuer: 'Fyra Stack' + issuer: dashboardBrand.title }); setupSubmitting = false; if (error) { diff --git a/apps/dashboard/src/lib/components/ui/avatar/avatar-fallback.svelte b/apps/dashboard/src/lib/components/ui/avatar/avatar-fallback.svelte index 49bee46..304b195 100644 --- a/apps/dashboard/src/lib/components/ui/avatar/avatar-fallback.svelte +++ b/apps/dashboard/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -13,7 +13,7 @@ bind:ref data-slot="avatar-fallback" class={cn( - 'flex size-full items-center justify-center bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs', + 'flex size-full items-center justify-center bg-muted text-sm text-foreground group-data-[size=sm]/avatar:text-xs', className )} {...restProps} diff --git a/apps/dashboard/src/lib/feature-flags.ts b/apps/dashboard/src/lib/feature-flags.ts index a9e4516..e795c3a 100644 --- a/apps/dashboard/src/lib/feature-flags.ts +++ b/apps/dashboard/src/lib/feature-flags.ts @@ -2,6 +2,7 @@ export const featureFlagKeys = [ 'colocation', 'firewall', 'images', + 'managedHosts', 'volumes', 'vpsConsole', 'vpsLogs', @@ -23,6 +24,7 @@ export const defaultFeatureFlags: FeatureFlags = { colocation: false, firewall: false, images: false, + managedHosts: true, volumes: false, vpsConsole: true, vpsLogs: true, @@ -40,6 +42,7 @@ export const developmentFeatureFlags: FeatureFlags = { colocation: true, firewall: true, images: true, + managedHosts: true, volumes: true, vpsConsole: true, vpsLogs: true, @@ -57,6 +60,7 @@ export const featureFlagLabels: Record = { colocation: 'Colocation', firewall: 'Firewall', images: 'Images', + managedHosts: 'Managed Hosts', volumes: 'Volumes', vpsConsole: 'Console Tab', vpsLogs: 'Logs Tab', @@ -74,6 +78,7 @@ export const featureFlagDescriptions: Record = { colocation: 'Enable colocation server management', firewall: 'Enable firewall rule management for projects', images: 'Enable custom image management and deployment', + managedHosts: 'Enable Tetra-managed host enrollment and control', volumes: 'Enable persistent volume management', vpsConsole: 'Web-based serial console access for VPS instances', vpsLogs: 'System and service log viewing for VPS instances', @@ -90,7 +95,7 @@ export const featureFlagDescriptions: Record = { export type FeatureFlagCategory = 'platform' | 'server'; export const featureFlagCategories: Record = { - platform: ['colocation', 'firewall', 'images', 'volumes'], + platform: ['colocation', 'firewall', 'images', 'managedHosts', 'volumes'], server: [ 'vpsConsole', 'vpsLogs', diff --git a/apps/dashboard/src/lib/remote/feature-flags.remote.ts b/apps/dashboard/src/lib/remote/feature-flags.remote.ts index 653be9f..cc2bfca 100644 --- a/apps/dashboard/src/lib/remote/feature-flags.remote.ts +++ b/apps/dashboard/src/lib/remote/feature-flags.remote.ts @@ -7,7 +7,7 @@ import { setFeatureFlag } from '$lib/server/feature-flags'; import type { FeatureFlagKey } from '$lib/feature-flags'; const featureFlagParam = - "'colocation' | 'firewall' | 'images' | 'volumes' | 'vpsConsole' | 'vpsLogs' | 'vpsNetworking' | 'vpsImages' | 'vpsSnapshots' | 'vpsBackups' | 'vpsRebuild' | 'vpsResize' | 'vpsRescue' | 'vpsSettings'"; + "'colocation' | 'firewall' | 'images' | 'managedHosts' | 'volumes' | 'vpsConsole' | 'vpsLogs' | 'vpsNetworking' | 'vpsImages' | 'vpsSnapshots' | 'vpsBackups' | 'vpsRebuild' | 'vpsResize' | 'vpsRescue' | 'vpsSettings'"; const updateFeatureFlagParams = type({ flag: featureFlagParam, diff --git a/apps/dashboard/src/lib/remote/managed-hosts.remote.ts b/apps/dashboard/src/lib/remote/managed-hosts.remote.ts new file mode 100644 index 0000000..06bdb34 --- /dev/null +++ b/apps/dashboard/src/lib/remote/managed-hosts.remote.ts @@ -0,0 +1,987 @@ +import { command, getRequestEvent, query } from '$app/server'; +import { error } from '@sveltejs/kit'; +import { type } from 'arktype'; +import { and, desc, eq } from 'drizzle-orm'; +import { requireProjectAccess } from '$lib/server/auth-context'; +import { initDrizzle } from '$lib/server/db'; +import { managedHosts } from '$lib/server/db/schema'; +import { createTetraClient, type AgentResponse } from '$lib/server/tetra/client'; +import { + accessibilityFixtureEnabled, + accessibilityFixtureManagedHosts +} from '$lib/server/accessibility-fixtures'; + +export type ManagedHost = { + id: string; + displayName: string; + ownerProjectId: string; + hostKind: 'stack_vps' | 'external' | 'local'; + linkedVmId: string | null; + connectionMode: 'direct_http' | 'websocket' | 'vsock_gateway' | 'offline'; + connectionState: 'online' | 'offline' | 'unknown'; + agentUrl: string | null; + lastSeenAt: number | null; + agentVersion: string | null; + hostname: string | null; + os: string | null; + arch: string | null; + capabilities: Record | null; + lastError: string | null; + createdAt: number; + updatedAt: number; +}; + +export type ManagedHostPodmanResource = 'containers' | 'images' | 'volumes' | 'networks'; + +export type ManagedHostPodmanResult = { + command: string | null; + data: unknown[]; + stdout: string; + stderr: string; +}; + +export type ManagedHostPodmanContainerDetail = { + name: string; + id: string | null; + image: string | null; + state: string | null; + status: string | null; + createdAt: string | null; + env: string[]; + binds: string[]; + ports: string[]; + labels: Record; + rawInspect: unknown; + logs: string; +}; + +export type ManagedHostQuadletScope = 'user' | 'system'; + +export type ManagedHostQuadletFile = { + filename: string; + path: string | null; + quadlet: boolean; +}; + +export type ManagedHostQuadletList = { + baseDir: string | null; + filesBaseDir: string | null; + files: ManagedHostQuadletFile[]; +}; + +export type ManagedHostQuadletCompanionFile = { + filename: string; + contents: string; +}; + +export type ManagedHostQuadletResource = { + filename: string; + contents: string; +}; + +export type ManagedHostQuadletDetail = { + scope: ManagedHostQuadletScope; + baseDir: string | null; + filesBaseDir: string | null; + filename: string; + contents: string; + files: ManagedHostQuadletCompanionFile[]; +}; + +function requireUser() { + const event = getRequestEvent(); + if (!event?.locals.user) error(401, 'Authentication required'); + return event.locals.user; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getStringField(value: Record | null | undefined, keys: string[]) { + for (const key of keys) { + const field = value?.[key]; + if (typeof field === 'string' && field.trim()) return field.trim(); + } + + return null; +} + +function getSystemOs(system: Record | null | undefined, fallback: string | null) { + return ( + getStringField(system, [ + 'pretty_name', + 'prettyName', + 'distro', + 'distribution', + 'os_name', + 'osName' + ]) ?? + getStringField(system, ['id', 'os']) ?? + fallback + ); +} + +function mapHost(row: typeof managedHosts.$inferSelect): ManagedHost { + return { + id: row.id, + displayName: row.displayName, + ownerProjectId: row.ownerProjectId, + hostKind: row.hostKind, + linkedVmId: row.linkedVmId, + connectionMode: row.connectionMode, + connectionState: row.connectionState, + agentUrl: row.agentUrl, + lastSeenAt: row.lastSeenAt, + agentVersion: row.agentVersion, + hostname: row.hostname, + os: row.os, + arch: row.arch, + capabilities: row.capabilities, + lastError: row.lastError, + createdAt: row.createdAt, + updatedAt: row.updatedAt + }; +} + +async function loadManagedHost(hostId: string) { + const user = requireUser(); + const db = initDrizzle(); + const host = await db.query.managedHosts.findFirst({ + where: eq(managedHosts.id, hostId) + }); + + if (!host) error(404, 'Managed host not found'); + + await requireProjectAccess(db, user.id, host.ownerProjectId); + return { db, host }; +} + +async function refreshHostCapabilities(host: typeof managedHosts.$inferSelect) { + const client = createTetraClient({ + connectionMode: host.connectionMode, + agentUrl: host.agentUrl, + bearerToken: host.bearerToken + }); + + await client.health(); + const capabilities = await client.capabilities(); + let system: AgentResponse | null = null; + + try { + system = await client.dispatch({ + module: 'settings', + action: 'get_system', + payload: {} + }); + } catch { + system = null; + } + + return { + capabilities: isRecord(capabilities.payload) ? capabilities.payload : null, + system: isRecord(system?.payload) ? system.payload : null + }; +} + +function mapPodmanResponse(response: AgentResponse): ManagedHostPodmanResult { + if (!response.ok) { + throw new Error(response.error || 'Podman command failed'); + } + + const payload = isRecord(response.payload) ? response.payload : {}; + const data = Array.isArray(payload.data) ? payload.data : []; + + return { + command: typeof payload.command === 'string' ? payload.command : null, + data, + stdout: typeof payload.stdout === 'string' ? payload.stdout : '', + stderr: typeof payload.stderr === 'string' ? payload.stderr : '' + }; +} + +function fixturePodmanResult(resource: ManagedHostPodmanResource): ManagedHostPodmanResult { + const fixtures: Record = { + containers: [ + { + Id: '8f9a1d2c3b4a', + Names: ['demo-web'], + Image: 'ghcr.io/example/demo-web:latest', + State: 'running', + Status: 'Up 2 hours' + }, + { + Id: '1a2b3c4d5e6f', + Names: ['demo-worker'], + Image: 'ghcr.io/example/demo-worker:latest', + State: 'exited', + Status: 'Exited 0 yesterday' + } + ], + images: [ + { + Id: 'sha256:111122223333', + Repository: 'ghcr.io/example/demo-web', + Tag: 'latest', + Size: '182 MB' + } + ], + volumes: [ + { + Name: 'demo-data', + Driver: 'local', + Mountpoint: '/var/lib/containers/storage/volumes/demo-data/_data' + } + ], + networks: [ + { + Name: 'podman', + Driver: 'bridge', + NetworkInterface: 'podman0' + } + ] + }; + + return { + command: `podman ${resource}`, + data: fixtures[resource], + stdout: '', + stderr: '' + }; +} + +function fixturePodmanContainerDetail(name: string): ManagedHostPodmanContainerDetail { + const isWorker = name === 'demo-worker'; + + return { + name, + id: isWorker ? '1a2b3c4d5e6f' : '8f9a1d2c3b4a', + image: isWorker ? 'ghcr.io/example/demo-worker:latest' : 'ghcr.io/example/demo-web:latest', + state: isWorker ? 'exited' : 'running', + status: isWorker ? 'Exited 0 yesterday' : 'Up 2 hours', + createdAt: '2026-01-01T00:00:00.000Z', + env: ['NODE_ENV=production', 'APP_PORT=8080', 'LOG_LEVEL=info'], + binds: ['/srv/demo/config:/etc/demo:ro', 'demo-data:/var/lib/demo:Z'], + ports: ['8080/tcp -> 0.0.0.0:8080', '8443/tcp -> 0.0.0.0:8443'], + labels: { + 'app.stack.fixture': 'true', + 'io.containers.autoupdate': 'registry' + }, + rawInspect: { + Name: `/${name}`, + Id: isWorker ? '1a2b3c4d5e6f' : '8f9a1d2c3b4a', + Config: { + Image: isWorker ? 'ghcr.io/example/demo-worker:latest' : 'ghcr.io/example/demo-web:latest', + Env: ['NODE_ENV=production', 'APP_PORT=8080', 'LOG_LEVEL=info'], + Labels: { + 'app.stack.fixture': 'true', + 'io.containers.autoupdate': 'registry' + } + }, + State: { Status: isWorker ? 'exited' : 'running' }, + HostConfig: { + Binds: ['/srv/demo/config:/etc/demo:ro', 'demo-data:/var/lib/demo:Z'] + }, + NetworkSettings: { + Ports: { + '8080/tcp': [{ HostIp: '0.0.0.0', HostPort: '8080' }], + '8443/tcp': [{ HostIp: '0.0.0.0', HostPort: '8443' }] + } + } + }, + logs: isWorker + ? '2026-01-01T00:00:00Z worker booted\n2026-01-01T00:00:02Z queue drained\n' + : '2026-01-01T00:00:00Z demo-web started\n2026-01-01T00:00:01Z listening on :8080\n' + }; +} + +function fixtureQuadletFiles(): ManagedHostQuadletFile[] { + return [ + { + filename: 'demo-web.container', + path: '/home/a11y/.config/containers/systemd/demo-web.container', + quadlet: true + }, + { + filename: 'demo-web/index.html', + path: '/home/a11y/.local/share/tetra/quadlets/demo-web/index.html', + quadlet: false + }, + { + filename: 'demo-web/default.conf', + path: '/home/a11y/.local/share/tetra/quadlets/demo-web/default.conf', + quadlet: false + } + ]; +} + +function fixtureQuadletDetail( + filename: string, + scope: ManagedHostQuadletScope +): ManagedHostQuadletDetail { + const filesBaseDir = + scope === 'system' + ? '/var/lib/tetra/quadlets/demo-web' + : '/home/a11y/.local/share/tetra/quadlets/demo-web'; + return { + scope, + baseDir: + scope === 'system' + ? '/etc/containers/systemd' + : '/home/a11y/.config/containers/systemd', + filesBaseDir, + filename, + contents: + `[Unit]\nDescription=Demo web site\n\n[Container]\nImage=docker.io/library/nginx:alpine\nContainerName=demo-web\nPublishPort=8080:80\nVolume=${filesBaseDir}:/usr/share/nginx/html:ro\nVolume=${filesBaseDir}/default.conf:/etc/nginx/conf.d/default.conf:ro\n\n[Service]\nRestart=always\n\n[Install]\nWantedBy=default.target\n`, + files: [ + { + filename: 'index.html', + contents: '\nDemo Web\n

Hello from a Quadlet companion file

\n' + }, + { + filename: 'default.conf', + contents: + 'server {\n listen 80;\n server_name _;\n root /usr/share/nginx/html;\n index index.html;\n}\n' + } + ] + }; +} + +function firstRecord(value: unknown): Record { + if (Array.isArray(value) && isRecord(value[0])) return value[0]; + if (isRecord(value)) return value; + return {}; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function nestedRecord(value: Record, key: string): Record { + return isRecord(value[key]) ? value[key] : {}; +} + +function mapLabels(value: unknown): Record { + if (!isRecord(value)) return {}; + return Object.fromEntries( + Object.entries(value) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string') + .sort(([a], [b]) => a.localeCompare(b)) + ); +} + +function mapPortBindings(value: unknown): string[] { + if (!isRecord(value)) return []; + + return Object.entries(value).flatMap(([containerPort, bindings]) => { + if (!Array.isArray(bindings) || bindings.length === 0) return [`${containerPort} -> unpublished`]; + + return bindings.filter(isRecord).map((binding) => { + const hostIp = typeof binding.HostIp === 'string' && binding.HostIp ? binding.HostIp : '0.0.0.0'; + const hostPort = + typeof binding.HostPort === 'string' && binding.HostPort ? binding.HostPort : 'auto'; + return `${containerPort} -> ${hostIp}:${hostPort}`; + }); + }); +} + +function mapContainerDetail( + name: string, + inspectPayload: unknown, + logs: string +): ManagedHostPodmanContainerDetail { + const inspect = firstRecord(inspectPayload); + const config = nestedRecord(inspect, 'Config'); + const hostConfig = nestedRecord(inspect, 'HostConfig'); + const state = nestedRecord(inspect, 'State'); + const networkSettings = nestedRecord(inspect, 'NetworkSettings'); + const rawName = typeof inspect.Name === 'string' ? inspect.Name.replace(/^\/+/, '') : name; + + return { + name: rawName || name, + id: typeof inspect.Id === 'string' ? inspect.Id : null, + image: + typeof config.Image === 'string' + ? config.Image + : typeof inspect.ImageName === 'string' + ? inspect.ImageName + : null, + state: + typeof state.Status === 'string' + ? state.Status + : typeof inspect.State === 'string' + ? inspect.State + : null, + status: typeof inspect.Status === 'string' ? inspect.Status : null, + createdAt: typeof inspect.Created === 'string' ? inspect.Created : null, + env: stringArray(config.Env), + binds: stringArray(hostConfig.Binds), + ports: mapPortBindings(networkSettings.Ports ?? hostConfig.PortBindings), + labels: mapLabels(config.Labels), + rawInspect: inspectPayload, + logs + }; +} + +function mapQuadletFile(value: unknown): ManagedHostQuadletFile | null { + if (!isRecord(value)) return null; + const filename = typeof value.filename === 'string' ? value.filename : null; + if (!filename) return null; + return { + filename, + path: typeof value.path === 'string' ? value.path : null, + quadlet: value.quadlet === true + }; +} + +function mapQuadletList(response: AgentResponse): ManagedHostQuadletList { + if (!response.ok) { + throw new Error(response.error || 'Failed to list Quadlet files'); + } + + const payload = isRecord(response.payload) ? response.payload : {}; + const files = Array.isArray(payload.files) + ? payload.files.map(mapQuadletFile).filter((file): file is ManagedHostQuadletFile => !!file) + : []; + + return { + baseDir: typeof payload.base_dir === 'string' ? payload.base_dir : null, + filesBaseDir: typeof payload.files_base_dir === 'string' ? payload.files_base_dir : null, + files: files.sort((left, right) => left.filename.localeCompare(right.filename)) + }; +} + +function mapQuadletRead(response: AgentResponse, filename: string) { + if (!response.ok) throw new Error(response.error || `Failed to read ${filename}`); + const payload = isRecord(response.payload) ? response.payload : {}; + return { + baseDir: typeof payload.base_dir === 'string' ? payload.base_dir : null, + filename: typeof payload.filename === 'string' ? payload.filename : filename, + contents: typeof payload.contents === 'string' ? payload.contents : '' + }; +} + +function quadletBundleName(filename: string) { + return filename.replace(/^.*\//, '').replace(/\.(container|kube|network|pod|volume)$/, ''); +} + +function companionFilenameForEditor(filename: string, bundleName: string) { + const prefix = `${bundleName}/`; + return filename.startsWith(prefix) ? filename.slice(prefix.length) : filename; +} + +async function dispatchHostCommand( + host: typeof managedHosts.$inferSelect, + command: { module: string; action: string; payload: Record } +) { + const client = createTetraClient({ + connectionMode: host.connectionMode, + agentUrl: host.agentUrl, + bearerToken: host.bearerToken + }); + return client.dispatch(command); +} + +async function markHostDispatchResult( + db: ReturnType, + host: typeof managedHosts.$inferSelect, + response: AgentResponse +) { + const now = Date.now(); + await db + .update(managedHosts) + .set({ + connectionState: 'online', + lastSeenAt: now, + lastError: response.ok ? null : response.error || null, + updatedAt: now + }) + .where(and(eq(managedHosts.id, host.id), eq(managedHosts.ownerProjectId, host.ownerProjectId))); +} + +const listParams = type({ projectId: 'string' }); +export const listManagedHosts = query(listParams, async (params): Promise => { + const user = requireUser(); + if (accessibilityFixtureEnabled) { + return accessibilityFixtureManagedHosts.filter( + (host) => host.ownerProjectId === params.projectId + ); + } + + const db = initDrizzle(); + await requireProjectAccess(db, user.id, params.projectId); + + const rows = await db.query.managedHosts.findMany({ + where: eq(managedHosts.ownerProjectId, params.projectId), + orderBy: [desc(managedHosts.createdAt)] + }); + + return rows.map(mapHost); +}); + +const getParams = type({ hostId: 'string' }); +export const getManagedHost = query(getParams, async (params): Promise => { + if (accessibilityFixtureEnabled) { + const host = accessibilityFixtureManagedHosts.find((item) => item.id === params.hostId); + if (!host) error(404, 'Managed host not found'); + return host; + } + + const { host } = await loadManagedHost(params.hostId); + return mapHost(host); +}); + +const createParams = type({ + projectId: 'string', + displayName: 'string', + agentUrl: 'string', + bearerToken: 'string?', + linkedVmId: 'string?' +}); +export const createManagedHost = command(createParams, async (params): Promise => { + const user = requireUser(); + const db = initDrizzle(); + await requireProjectAccess(db, user.id, params.projectId, 'admin'); + + const displayName = params.displayName.trim(); + if (!displayName) error(400, 'Display name is required'); + + let agentUrl: string; + try { + agentUrl = new URL(params.agentUrl).toString().replace(/\/+$/, ''); + } catch { + error(400, 'Agent URL must be a valid URL'); + } + + const [host] = await db + .insert(managedHosts) + .values({ + displayName, + ownerProjectId: params.projectId, + hostKind: params.linkedVmId ? 'stack_vps' : 'external', + linkedVmId: params.linkedVmId ?? null, + connectionMode: 'direct_http', + connectionState: 'unknown', + agentUrl, + bearerToken: params.bearerToken?.trim() || null + }) + .returning(); + + return mapHost(host); +}); + +export const deleteManagedHost = command(getParams, async (params) => { + if (accessibilityFixtureEnabled) return; + + const user = requireUser(); + const { db, host } = await loadManagedHost(params.hostId); + await requireProjectAccess(db, user.id, host.ownerProjectId, 'admin'); + + await db.delete(managedHosts).where(eq(managedHosts.id, host.id)); +}); + +export const refreshManagedHostCapabilities = command( + getParams, + async (params): Promise => { + const { db, host } = await loadManagedHost(params.hostId); + + try { + const result = await refreshHostCapabilities(host); + const now = Date.now(); + const [updated] = await db + .update(managedHosts) + .set({ + connectionState: 'online', + lastSeenAt: now, + capabilities: result.capabilities, + os: getSystemOs(result.system, host.os), + arch: typeof result.system?.arch === 'string' ? result.system.arch : host.arch, + lastError: null, + updatedAt: now + }) + .where(eq(managedHosts.id, host.id)) + .returning(); + + return mapHost(updated); + } catch (err) { + const now = Date.now(); + const [updated] = await db + .update(managedHosts) + .set({ + connectionState: 'offline', + lastError: err instanceof Error ? err.message : 'Failed to refresh host capabilities', + updatedAt: now + }) + .where(eq(managedHosts.id, host.id)) + .returning(); + + return mapHost(updated); + } + } +); + +const dispatchParams = type({ + hostId: 'string', + module: 'string', + action: 'string', + payloadJson: 'string' +}); +export const dispatchManagedHostCommand = command(dispatchParams, async (params) => { + const user = requireUser(); + const { db, host } = await loadManagedHost(params.hostId); + await requireProjectAccess(db, user.id, host.ownerProjectId, 'admin'); + + let payload: Record; + try { + const parsed = JSON.parse(params.payloadJson); + payload = isRecord(parsed) ? parsed : {}; + } catch { + error(400, 'Payload must be valid JSON'); + } + + const response = await dispatchHostCommand(host, { + module: params.module.trim(), + action: params.action.trim(), + payload + }); + + await markHostDispatchResult(db, host, response); + + return response; +}); + +const podmanListParams = type({ + hostId: 'string', + resource: 'string' +}); +export const listManagedHostPodman = command( + podmanListParams, + async (params): Promise => { + if (!['containers', 'images', 'volumes', 'networks'].includes(params.resource)) { + error(400, 'Unsupported Podman resource'); + } + + const resource = params.resource as ManagedHostPodmanResource; + if (accessibilityFixtureEnabled) return fixturePodmanResult(resource); + + const user = requireUser(); + const { db, host } = await loadManagedHost(params.hostId); + await requireProjectAccess(db, user.id, host.ownerProjectId); + + const response = await dispatchHostCommand(host, { + module: 'podman', + action: resource, + payload: {} + }); + await markHostDispatchResult(db, host, response); + return mapPodmanResponse(response); + } +); + +const podmanLogsParams = type({ + hostId: 'string', + name: 'string', + lines: 'number' +}); +export const getManagedHostPodmanLogs = command(podmanLogsParams, async (params) => { + if (accessibilityFixtureEnabled) return fixturePodmanContainerDetail(params.name).logs; + + const user = requireUser(); + const { db, host } = await loadManagedHost(params.hostId); + await requireProjectAccess(db, user.id, host.ownerProjectId); + + const response = await dispatchHostCommand(host, { + module: 'podman', + action: 'logs', + payload: { + name: params.name, + lines: Math.max(1, Math.min(1000, Math.trunc(params.lines))) + } + }); + await markHostDispatchResult(db, host, response); + + if (!response.ok) throw new Error(response.error || 'Failed to load container logs'); + const payload = isRecord(response.payload) ? response.payload : {}; + return typeof payload.stdout === 'string' ? payload.stdout : ''; +}); + +export const getManagedHostPodmanContainer = command( + podmanLogsParams, + async (params): Promise => { + const lines = Math.max(1, Math.min(1000, Math.trunc(params.lines))); + if (accessibilityFixtureEnabled) return fixturePodmanContainerDetail(params.name); + + const user = requireUser(); + const { db, host } = await loadManagedHost(params.hostId); + await requireProjectAccess(db, user.id, host.ownerProjectId); + + const inspectResponse = await dispatchHostCommand(host, { + module: 'podman', + action: 'inspect', + payload: { name: params.name } + }); + await markHostDispatchResult(db, host, inspectResponse); + if (!inspectResponse.ok) { + throw new Error(inspectResponse.error || 'Failed to inspect container'); + } + + const logsResponse = await dispatchHostCommand(host, { + module: 'podman', + action: 'logs', + payload: { name: params.name, lines } + }); + await markHostDispatchResult(db, host, logsResponse); + if (!logsResponse.ok) { + throw new Error(logsResponse.error || 'Failed to load container logs'); + } + + const inspectPayload = isRecord(inspectResponse.payload) + ? inspectResponse.payload.data + : inspectResponse.payload; + const logsPayload = isRecord(logsResponse.payload) ? logsResponse.payload : {}; + const logs = typeof logsPayload.stdout === 'string' ? logsPayload.stdout : ''; + + return mapContainerDetail(params.name, inspectPayload, logs); + } +); + +const podmanActionParams = type({ + hostId: 'string', + name: 'string', + action: 'string' +}); +export const runManagedHostPodmanContainerAction = command(podmanActionParams, async (params) => { + if (!['start', 'stop', 'restart', 'remove'].includes(params.action)) { + error(400, 'Unsupported Podman container action'); + } + + if (accessibilityFixtureEnabled) { + return { + id: 'fixture-podman-action', + ok: true, + payload: { command: `podman ${params.action} ${params.name}`, status: 0 } + } satisfies AgentResponse; + } + + const user = requireUser(); + const { db, host } = await loadManagedHost(params.hostId); + await requireProjectAccess(db, user.id, host.ownerProjectId, 'admin'); + + const response = await dispatchHostCommand(host, { + module: 'podman', + action: params.action, + payload: { name: params.name } + }); + await markHostDispatchResult(db, host, response); + + if (!response.ok) throw new Error(response.error || 'Podman container action failed'); + return response; +}); + +const quadletScopeValues = ['user', 'system'] as const; + +function normalizeQuadletScope(scope: string): ManagedHostQuadletScope { + if (quadletScopeValues.includes(scope as ManagedHostQuadletScope)) { + return scope as ManagedHostQuadletScope; + } + error(400, 'Unsupported Quadlet scope'); +} + +function parseCompanionFiles(filesJson: string): ManagedHostQuadletCompanionFile[] { + let parsed: unknown; + try { + parsed = JSON.parse(filesJson); + } catch { + error(400, 'Companion files must be valid JSON'); + } + + if (!Array.isArray(parsed)) error(400, 'Companion files must be a JSON array'); + return parsed.map((item, index) => { + if (!isRecord(item)) error(400, `Companion file ${index + 1} must be an object`); + const filename = typeof item.filename === 'string' ? item.filename.trim() : ''; + const contents = typeof item.contents === 'string' ? item.contents : ''; + if (!filename) error(400, `Companion file ${index + 1} needs a filename`); + return { filename, contents }; + }); +} + +function parseQuadletResources(resourcesJson: string): ManagedHostQuadletResource[] { + let parsed: unknown; + try { + parsed = JSON.parse(resourcesJson); + } catch { + error(400, 'Quadlet resources must be valid JSON'); + } + + if (!Array.isArray(parsed)) error(400, 'Quadlet resources must be a JSON array'); + return parsed.map((item, index) => { + if (!isRecord(item)) error(400, `Quadlet resource ${index + 1} must be an object`); + const filename = typeof item.filename === 'string' ? item.filename.trim() : ''; + const contents = typeof item.contents === 'string' ? item.contents.trimEnd() + '\n' : ''; + if (!filename) error(400, `Quadlet resource ${index + 1} needs a filename`); + if (!contents.trim()) error(400, `Quadlet resource ${index + 1} needs contents`); + return { filename, contents }; + }); +} + +const quadletListParams = type({ + hostId: 'string', + scope: 'string' +}); + +export const listManagedHostQuadlets = command( + quadletListParams, + async (params): Promise => { + const scope = normalizeQuadletScope(params.scope); + if (accessibilityFixtureEnabled) { + return { + baseDir: + scope === 'system' + ? '/etc/containers/systemd' + : '/home/a11y/.config/containers/systemd', + filesBaseDir: + scope === 'system' ? '/var/lib/tetra/quadlets' : '/home/a11y/.local/share/tetra/quadlets', + files: fixtureQuadletFiles() + }; + } + + const user = requireUser(); + const { db, host } = await loadManagedHost(params.hostId); + await requireProjectAccess(db, user.id, host.ownerProjectId); + + const response = await dispatchHostCommand(host, { + module: 'quadlets', + action: 'list_files', + payload: { scope } + }); + await markHostDispatchResult(db, host, response); + return mapQuadletList(response); + } +); + +const quadletReadParams = type({ + hostId: 'string', + scope: 'string', + filename: 'string' +}); + +export const getManagedHostQuadlet = command( + quadletReadParams, + async (params): Promise => { + const scope = normalizeQuadletScope(params.scope); + const filename = params.filename.trim(); + if (!filename) error(400, 'Quadlet filename is required'); + if (accessibilityFixtureEnabled) return fixtureQuadletDetail(filename, scope); + + const user = requireUser(); + const { db, host } = await loadManagedHost(params.hostId); + await requireProjectAccess(db, user.id, host.ownerProjectId); + + const listResponse = await dispatchHostCommand(host, { + module: 'quadlets', + action: 'list_files', + payload: { scope } + }); + await markHostDispatchResult(db, host, listResponse); + const list = mapQuadletList(listResponse); + + const readResponse = await dispatchHostCommand(host, { + module: 'quadlets', + action: 'read', + payload: { scope, filename } + }); + await markHostDispatchResult(db, host, readResponse); + const quadlet = mapQuadletRead(readResponse, filename); + const bundleName = quadletBundleName(filename); + + const companionFiles: ManagedHostQuadletCompanionFile[] = []; + for (const file of list.files.filter( + (item) => !item.quadlet && item.filename.startsWith(`${bundleName}/`) + )) { + const fileResponse = await dispatchHostCommand(host, { + module: 'quadlets', + action: 'read', + payload: { scope, filename: file.filename, companion: true } + }); + await markHostDispatchResult(db, host, fileResponse); + companionFiles.push({ + filename: companionFilenameForEditor(file.filename, bundleName), + contents: mapQuadletRead(fileResponse, file.filename).contents + }); + } + + return { + scope, + baseDir: list.baseDir ?? quadlet.baseDir, + filesBaseDir: list.filesBaseDir ? `${list.filesBaseDir}/${bundleName}` : null, + filename: quadlet.filename, + contents: quadlet.contents, + files: companionFiles + }; + } +); + +const quadletSaveParams = type({ + hostId: 'string', + scope: 'string', + filename: 'string', + contents: 'string', + filesJson: 'string', + resourcesJson: 'string?' +}); + +export const saveManagedHostQuadlet = command(quadletSaveParams, async (params) => { + const scope = normalizeQuadletScope(params.scope); + const filename = params.filename.trim(); + const contents = params.contents.trimEnd() + '\n'; + const files = parseCompanionFiles(params.filesJson); + const resources = params.resourcesJson + ? parseQuadletResources(params.resourcesJson) + : [{ filename, contents }]; + if (!filename) error(400, 'Quadlet filename is required'); + if (!contents.trim()) error(400, 'Quadlet contents are required'); + if (!resources.some((resource) => resource.filename === filename)) { + resources.unshift({ filename, contents }); + } + + if (accessibilityFixtureEnabled) { + return { + id: 'fixture-quadlet-install', + ok: true, + payload: { + base_dir: + scope === 'system' + ? '/etc/containers/systemd' + : '/home/a11y/.config/containers/systemd', + files_base_dir: + scope === 'system' + ? `/var/lib/tetra/quadlets/${quadletBundleName(filename)}` + : `/home/a11y/.local/share/tetra/quadlets/${quadletBundleName(filename)}`, + installed: resources.map((resource) => ({ filename: resource.filename })), + files, + written: true, + dry_run: false + } + } satisfies AgentResponse; + } + + const user = requireUser(); + const { db, host } = await loadManagedHost(params.hostId); + await requireProjectAccess(db, user.id, host.ownerProjectId, 'admin'); + + const response = await dispatchHostCommand(host, { + module: 'quadlets', + action: 'install', + payload: { + scope, + resources, + files + } + }); + await markHostDispatchResult(db, host, response); + + if (!response.ok) throw new Error(response.error || 'Failed to save Quadlet bundle'); + return response; +}); diff --git a/apps/dashboard/src/lib/server/accessibility-fixtures.ts b/apps/dashboard/src/lib/server/accessibility-fixtures.ts index c7a6f1c..8ee684a 100644 --- a/apps/dashboard/src/lib/server/accessibility-fixtures.ts +++ b/apps/dashboard/src/lib/server/accessibility-fixtures.ts @@ -40,6 +40,7 @@ export const accessibilityFixtureFeatureFlags: FeatureFlags = { colocation: false, firewall: false, images: false, + managedHosts: true, volumes: false, vpsConsole: false, vpsLogs: false, @@ -55,6 +56,62 @@ export const accessibilityFixtureFeatureFlags: FeatureFlags = { export const accessibilityFixtureProjects = [accessibilityFixtureProject]; +export const accessibilityFixtureManagedHosts = [ + { + id: 'accessibility-host', + displayName: 'a11y-host-01', + ownerProjectId: accessibilityFixtureProject.id, + hostKind: 'external' as const, + linkedVmId: null, + connectionMode: 'direct_http' as const, + connectionState: 'online' as const, + agentUrl: 'http://127.0.0.1:7777', + lastSeenAt: now.getTime(), + agentVersion: 'fixture', + hostname: 'a11y-host-01', + os: 'Ultramarine Linux', + arch: 'x86_64', + capabilities: { + modules: [ + { + name: 'settings', + feature: 'settings', + description: 'Inspect host settings.', + status: 'available', + actions: ['get_system'] + }, + { + name: 'podman', + feature: 'podman', + description: 'Inspect and manage Podman resources.', + status: 'available', + actions: [ + 'containers', + 'inspect', + 'images', + 'volumes', + 'networks', + 'logs', + 'start', + 'stop', + 'restart' + ] + }, + { + name: 'quadlets', + feature: 'quadlets', + description: 'Manage Quadlet files and companion resources.', + status: 'available', + actions: ['list', 'list_files', 'read', 'write', 'delete', 'validate', 'install'] + } + ] + }, + lastError: null, + createdAt: now.getTime(), + updatedAt: now.getTime() + } +]; + export const accessibilityFixtureProjectDetails = { id: accessibilityFixtureProject.id, projectName: accessibilityFixtureProject.projectName, diff --git a/apps/dashboard/src/lib/server/auth.ts b/apps/dashboard/src/lib/server/auth.ts index ea67434..75cbb76 100644 --- a/apps/dashboard/src/lib/server/auth.ts +++ b/apps/dashboard/src/lib/server/auth.ts @@ -20,6 +20,7 @@ import { sendSecurityAlertEmail } from '$lib/server/email-notifications'; import { updateProjectCustomer } from '$lib/server/billing/autumn'; import { getRuntimeEnv } from '$lib/server/env'; import { ulid } from '$lib/server/id'; +import { dashboardBrand } from '$lib/branding'; const PENDING_PASSKEY_COOKIE = 'pending_passkey_2fa'; const PENDING_PASSKEY_HINT_COOKIE = 'pending_passkey_2fa_hint'; @@ -121,7 +122,7 @@ function buildAuth() { const baseURL = dev ? getRequestEvent().url.origin : env.ORIGIN; return betterAuth({ - appName: 'Stack', + appName: dashboardBrand.title, baseURL, secret: env.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { provider: 'pg' }), diff --git a/apps/dashboard/src/lib/server/db/schema.ts b/apps/dashboard/src/lib/server/db/schema.ts index 1caacb0..2ccff10 100644 --- a/apps/dashboard/src/lib/server/db/schema.ts +++ b/apps/dashboard/src/lib/server/db/schema.ts @@ -7,6 +7,7 @@ import { integer, numeric, date, + jsonb, index, uniqueIndex, inet, @@ -27,6 +28,21 @@ export const vmBackendEnum = pgEnum('vm_backend', ['proxmox']); export const vmStatusEnum = pgEnum('vm_status', ['provisioning', 'ready', 'error', 'deleting']); +export const managedHostKindEnum = pgEnum('managed_host_kind', ['stack_vps', 'external', 'local']); + +export const managedHostConnectionModeEnum = pgEnum('managed_host_connection_mode', [ + 'direct_http', + 'websocket', + 'vsock_gateway', + 'offline' +]); + +export const managedHostConnectionStateEnum = pgEnum('managed_host_connection_state', [ + 'online', + 'offline', + 'unknown' +]); + export const billingSyncStatusEnum = pgEnum('billing_sync_status', [ 'pending', 'synced', @@ -102,11 +118,64 @@ export const vmsRelations = relations(vms, ({ one, many }) => ({ references: [vmTypes.id] }), volumes: many(volumes), + managedHosts: many(managedHosts), paymentPeriods: many(paymentPeriods), ipAssignments: many(ipAssignments), ipamAllocations: many(ipamAllocations) })); +// Managed Hosts + +export const managedHosts = pgTable( + 'managed_hosts', + { + id: ulidPk(), + displayName: text('display_name').notNull(), + ownerProjectId: ulidFk('owner_project_id') + .notNull() + .references(() => organization.id), + hostKind: managedHostKindEnum('host_kind').notNull().default('external'), + linkedVmId: ulidFk('linked_vm_id').references(() => vms.id), + connectionMode: managedHostConnectionModeEnum('connection_mode') + .notNull() + .default('direct_http'), + connectionState: managedHostConnectionStateEnum('connection_state') + .notNull() + .default('unknown'), + agentUrl: text('agent_url'), + bearerToken: text('bearer_token'), + lastSeenAt: bigint('last_seen_at', { mode: 'number' }), + agentVersion: text('agent_version'), + hostname: text('hostname'), + os: text('os'), + arch: text('arch'), + capabilities: jsonb('capabilities').$type | null>(), + lastError: text('last_error'), + createdAt: bigint('created_at', { mode: 'number' }) + .notNull() + .default(sql`(extract(epoch from now()) * 1000)::bigint`), + updatedAt: bigint('updated_at', { mode: 'number' }) + .notNull() + .default(sql`(extract(epoch from now()) * 1000)::bigint`) + }, + (table) => [ + index('managed_hosts_owner_project_id_index').on(table.ownerProjectId), + index('managed_hosts_linked_vm_id_index').on(table.linkedVmId), + index('managed_hosts_connection_state_index').on(table.connectionState) + ] +); + +export const managedHostsRelations = relations(managedHosts, ({ one }) => ({ + project: one(organization, { + fields: [managedHosts.ownerProjectId], + references: [organization.id] + }), + vm: one(vms, { + fields: [managedHosts.linkedVmId], + references: [vms.id] + }) +})); + // Volumes export const volumes = pgTable( diff --git a/apps/dashboard/src/lib/server/tetra/client.ts b/apps/dashboard/src/lib/server/tetra/client.ts new file mode 100644 index 0000000..6d10797 --- /dev/null +++ b/apps/dashboard/src/lib/server/tetra/client.ts @@ -0,0 +1,98 @@ +import { ulid } from '$lib/server/id'; + +export type AgentCommand = { + id: string; + module: string; + action: string; + payload: Record; + signature?: string | null; +}; + +export type AgentResponse = { + id: string; + ok: boolean; + payload?: unknown; + error?: string; +}; + +export type TetraClient = { + health(): Promise<{ ok: boolean }>; + capabilities(): Promise; + dispatch(command: Omit & { id?: string }): Promise; +}; + +export class DirectHttpTetraClient implements TetraClient { + readonly #baseUrl: string; + readonly #bearerToken: string | null; + + constructor(baseUrl: string, bearerToken?: string | null) { + this.#baseUrl = baseUrl.replace(/\/+$/, ''); + this.#bearerToken = bearerToken?.trim() || null; + } + + async health() { + return this.#request<{ ok: boolean }>('/health'); + } + + async capabilities() { + return this.#request('/capabilities'); + } + + async dispatch(command: Omit & { id?: string }) { + return this.#request('/dispatch', { + method: 'POST', + body: JSON.stringify({ + id: command.id ?? `cmd-${ulid()}`, + module: command.module, + action: command.action, + payload: command.payload, + signature: command.signature ?? null + }) + }); + } + + async #request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set('accept', 'application/json'); + + if (init.body && !headers.has('content-type')) { + headers.set('content-type', 'application/json'); + } + + if (this.#bearerToken) { + headers.set('authorization', `Bearer ${this.#bearerToken}`); + } + + const response = await fetch(`${this.#baseUrl}${path}`, { + ...init, + headers + }); + const text = await response.text(); + const data = text ? JSON.parse(text) : null; + + if (!response.ok) { + const message = + (data && typeof data === 'object' && 'error' in data ? String(data.error) : '') || + `Tetra request failed with HTTP ${response.status}`; + throw new Error(message); + } + + return data as T; + } +} + +export function createTetraClient(options: { + connectionMode: string; + agentUrl: string | null; + bearerToken: string | null; +}): TetraClient { + if (options.connectionMode !== 'direct_http') { + throw new Error(`Tetra connection mode ${options.connectionMode} is not implemented yet`); + } + + if (!options.agentUrl) { + throw new Error('Direct HTTP Tetra hosts require an agent URL'); + } + + return new DirectHttpTetraClient(options.agentUrl, options.bearerToken); +} diff --git a/apps/dashboard/src/routes/(app)/+layout.svelte b/apps/dashboard/src/routes/(app)/+layout.svelte index 55c3667..42aa85d 100644 --- a/apps/dashboard/src/routes/(app)/+layout.svelte +++ b/apps/dashboard/src/routes/(app)/+layout.svelte @@ -21,6 +21,7 @@ import { untrack } from 'svelte'; import { listVms } from '$lib/remote/vms.remote'; import { authClient } from '$lib/auth-client'; + import { dashboardBrand, pageTitle } from '$lib/branding'; import type { FeatureFlags } from '$lib/feature-flags'; import type { IconComponent } from '$lib'; import ArrowRight from '~icons/lucide/arrow-right'; @@ -88,7 +89,11 @@ const items: { icon: IconComponent; label: string; href: string }[] = []; if (!currentProject) return items; const prefix = `/projects/${currentProject.id}`; - items.push({ icon: Server, label: 'Servers', href: `${prefix}/servers` }); + if (featureFlags.managedHosts) + items.push({ icon: Server, label: 'Managed Hosts', href: `${prefix}/hosts` }); + if (!dashboardBrand.isStandalone) { + items.push({ icon: Server, label: 'Servers', href: `${prefix}/servers` }); + } if (featureFlags.colocation) items.push({ icon: Warehouse, label: 'Colocation', href: `${prefix}/colocation` }); if (featureFlags.volumes) @@ -96,7 +101,9 @@ if (featureFlags.firewall) items.push({ icon: Shield, label: 'Firewall', href: `${prefix}/firewall` }); if (featureFlags.images) items.push({ icon: Disc, label: 'Images', href: `${prefix}/images` }); - items.push({ icon: CreditCard, label: 'Billing', href: `${prefix}/billing` }); + if (!dashboardBrand.isStandalone) { + items.push({ icon: CreditCard, label: 'Billing', href: `${prefix}/billing` }); + } items.push({ icon: Settings, label: 'Settings', href: `${prefix}/settings` }); return items; }); @@ -130,7 +137,7 @@ try { selectedProjectId = projectId; await authClient.organization.setActive({ organizationId: projectId }); - await goto(resolve(`/projects/${projectId}/servers`)); + await goto(resolve(`/projects/${projectId}/${dashboardBrand.defaultProjectPath}`)); } catch (error) { toast.error(getErrorMessage(error, 'Failed to switch project')); } finally { @@ -196,13 +203,19 @@ { id: 'all', label: 'All', icon: Search }, { id: 'navigate', label: 'Pages', icon: ArrowRight } ]; - if (showServerFilter) filters.push({ id: 'servers', label: 'Servers', icon: Server }); + if (!dashboardBrand.isStandalone && showServerFilter) { + filters.push({ id: 'servers', label: 'Servers', icon: Server }); + } filters.push({ id: 'account', label: 'Account', icon: User }); return filters; }); const navigateCommands = $derived.by(() => { - const commands: CommandEntry[] = [{ icon: Server, label: 'Servers', href: '/servers' }]; + const commands: CommandEntry[] = []; + if (showManagedHosts) commands.push({ icon: Server, label: 'Managed Hosts', href: '/hosts' }); + if (!dashboardBrand.isStandalone) { + commands.push({ icon: Server, label: 'Servers', href: '/servers' }); + } if (showColocation) commands.push({ icon: Warehouse, label: 'Colocation', href: '/colocation' }); if (showVolumes) commands.push({ icon: HardDrive, label: 'Volumes', href: '/volumes' }); @@ -248,6 +261,7 @@ } async function loadCommandServers(projectId = selectedProjectId) { + if (dashboardBrand.isStandalone) return; if (!projectId || commandServersLoading || commandServersLoadedProjectId === projectId) return; const requestId = ++commandServersRequestId; commandServersLoading = true; @@ -286,7 +300,7 @@ commandSearch = ''; cmdFilter = 'all'; commandOpen = true; - void loadCommandServers(); + if (!dashboardBrand.isStandalone) void loadCommandServers(); } $effect(() => { @@ -327,6 +341,7 @@ } const showColocation = $derived(!!featureFlags.colocation); + const showManagedHosts = $derived(!!featureFlags.managedHosts); const showVolumes = $derived(!!featureFlags.volumes); const showFirewall = $derived(!!featureFlags.firewall); const showImages = $derived(!!featureFlags.images); @@ -337,7 +352,7 @@ - Stack / Dashboard + {pageTitle('Dashboard')} {#if !data.user} @@ -356,8 +371,10 @@ {/if} - - Stack + + + {dashboardBrand.name} + {#if isOnProjectRoute} / @@ -381,16 +398,16 @@ > {project.projectName} {#if switchingProjectId === project.id} - + {:else if selectedProjectId === project.id} - + {/if} {/each} @@ -425,7 +442,7 @@ @@ -441,7 +458,7 @@

{data.user?.email}

- {(data.user?.name ?? '??') .split(' ') .map((n: string) => n[0]) @@ -475,7 +492,7 @@ class="flex h-8 w-8 items-center justify-center transition-colors duration-100 {isActive( item.href ) - ? 'border border-red-500 text-foreground' + ? 'border border-primary text-foreground' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'}" > @@ -542,12 +559,12 @@ > {project.projectName} {#if selectedProjectId === project.id} - + {/if} {/each} diff --git a/apps/dashboard/src/routes/(app)/+page.svelte b/apps/dashboard/src/routes/(app)/+page.svelte index 4fcbeca..40d85b2 100644 --- a/apps/dashboard/src/routes/(app)/+page.svelte +++ b/apps/dashboard/src/routes/(app)/+page.svelte @@ -20,6 +20,7 @@ import { toast } from 'svelte-sonner'; import { getErrorMessage } from '$lib/utils'; import { isProjectRole, projectRoleLabels } from '$lib/auth/organization-permissions'; + import { dashboardBrand, pageTitle } from '$lib/branding'; type Project = { id: string; projectName: string; role: string }; @@ -48,7 +49,7 @@ newProjectName = ''; createOpen = false; await authClient.organization.setActive({ organizationId: res.id }); - await goto(`/projects/${res.id}/servers`); + await goto(`/projects/${res.id}/${dashboardBrand.defaultProjectPath}`); } catch (err) { createProjectError = getErrorMessage(err, 'Project could not be created.'); } finally { @@ -56,7 +57,7 @@ } } - async function openProject(project: Project, path = 'servers') { + async function openProject(project: Project, path: string = dashboardBrand.defaultProjectPath) { await authClient.organization.setActive({ organizationId: project.id }); await goto(`/projects/${project.id}/${path}`); } @@ -109,7 +110,7 @@ - Projects / Stack + {pageTitle('Projects')}
@@ -172,7 +173,7 @@
diff --git a/apps/dashboard/src/routes/(app)/admin/features/+page.svelte b/apps/dashboard/src/routes/(app)/admin/features/+page.svelte index 122812a..21befb1 100644 --- a/apps/dashboard/src/routes/(app)/admin/features/+page.svelte +++ b/apps/dashboard/src/routes/(app)/admin/features/+page.svelte @@ -35,6 +35,7 @@ colocation: Server, firewall: Shield, images: Image, + managedHosts: Server, volumes: HardDrive, vpsConsole: Terminal, vpsLogs: FileText, diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/+page.server.ts b/apps/dashboard/src/routes/(app)/projects/[projectid]/+page.server.ts index 81e01b4..62e1ef7 100644 --- a/apps/dashboard/src/routes/(app)/projects/[projectid]/+page.server.ts +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/+page.server.ts @@ -1,6 +1,7 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; +import { dashboardBrand } from '$lib/branding'; export const load: PageServerLoad = async ({ params }) => { - throw redirect(303, `/projects/${params.projectid}/servers`); + throw redirect(303, `/projects/${params.projectid}/${dashboardBrand.defaultProjectPath}`); }; diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+layout.server.ts b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+layout.server.ts new file mode 100644 index 0000000..1ed75ba --- /dev/null +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+layout.server.ts @@ -0,0 +1,20 @@ +import type { LayoutServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { listManagedHosts } from '$lib/remote/managed-hosts.remote'; + +export const load: LayoutServerLoad = async ({ params, parent, depends }) => { + depends('project:managed-hosts'); + const { featureFlags } = await parent(); + + if (!featureFlags.managedHosts) { + error(404, 'Not found'); + } + + if (!params.projectid) { + error(404, 'Project not found'); + } + + return { + hosts: await listManagedHosts({ projectId: params.projectid }) + }; +}; diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+layout.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+layout.svelte new file mode 100644 index 0000000..51d2db3 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+layout.svelte @@ -0,0 +1,143 @@ + + +
+
+
+
+ Hosts + {hosts.length} +
+
+ {#if !isHostsIndex} + + {/if} + +
+
+ +
+ {#each hosts as host (host.id)} + +
+

+ {host.displayName} +

+

+ {host.connectionMode} • {formatRelative(host.lastSeenAt)} +

+
+ +
+ {/each} + + {#if hosts.length === 0} +
+ +

No hosts

+
+ {/if} +
+
+ +
+ {@render children()} +
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+page.server.ts b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+page.server.ts new file mode 100644 index 0000000..cbed9d9 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+page.server.ts @@ -0,0 +1,6 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + await parent(); + return {}; +}; diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+page.svelte new file mode 100644 index 0000000..1b6eb0b --- /dev/null +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/+page.svelte @@ -0,0 +1,77 @@ + + +
+
+
+ +

Managed Hosts

+
+

+ Select a host from the list to inspect capabilities and dispatch Tetra commands. +

+
+ +
+
+
+ +
+

+ {hosts.length === 0 ? 'No managed hosts yet' : 'Choose a host'} +

+

+ {#if hosts.length === 0} + Register a Tetra agent to start testing host discovery and command dispatch. + {:else} + There {hosts.length === 1 ? 'is' : 'are'} + {hosts.length} managed {hosts.length === 1 ? 'host' : 'hosts'} available. Pick one from the + list on the left to view its details. + {/if} +

+ + {#if hosts.length > 0} +
+
+

Online

+

{onlineHosts}

+
+
+

Offline

+

{offlineHosts}

+
+
+

Unknown

+

{unknownHosts}

+
+
+ {/if} + + +
+
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/+layout.server.ts b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/+layout.server.ts new file mode 100644 index 0000000..f050b23 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/+layout.server.ts @@ -0,0 +1,21 @@ +import type { LayoutServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { getManagedHost } from '$lib/remote/managed-hosts.remote'; + +export const load: LayoutServerLoad = async ({ params, parent, depends }) => { + depends(`managed-host:${params.id}`); + const { featureFlags } = await parent(); + + if (!featureFlags.managedHosts) { + error(404, 'Not found'); + } + + if (!params.projectid) { + error(404, 'Project not found'); + } + + const host = await getManagedHost({ hostId: params.id }); + if (host.ownerProjectId !== params.projectid) error(404, 'Managed host not found'); + + return { host }; +}; diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/+layout.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/+layout.svelte new file mode 100644 index 0000000..f24a59b --- /dev/null +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/+layout.svelte @@ -0,0 +1,166 @@ + + +
+
+ {host.displayName} + + {host.connectionState} + + +
+
+ + +
+
+ +
+ {#each hostTabs as tab (tab.id)} + + + {tab.label} + + {/each} +
+ +{#if actionError} +
+ {actionError} +
+{/if} + +
+ {@render children()} +
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/+page.svelte new file mode 100644 index 0000000..7f023f9 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/+page.svelte @@ -0,0 +1,79 @@ + + + +
+
+
+

Host Details

+

+ Current dashboard state for this Tetra agent. +

+ +
+ {#each [['Kind', host.hostKind], ['Mode', host.connectionMode], ['Last seen', formatDate(host.lastSeenAt)], ['OS', formatOs(host.os)], ['Arch', host.arch ?? 'Unknown'], ['Agent', host.agentVersion ?? 'Unknown']] as [label, value] (label)} +
+ {label} + {value} +
+ {/each} +
+ + {#if host.lastError} +
+ {host.lastError} +
+ {/if} +
+ +
+
+
+

Capabilities

+

+ Last successful `agent.capabilities` response. +

+
+
+ +
{formatJson(
+					host.capabilities
+				)}
+
+
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/dispatch/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/dispatch/+page.svelte new file mode 100644 index 0000000..a160af2 --- /dev/null +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/dispatch/+page.svelte @@ -0,0 +1,93 @@ + + +
+
+

Dispatch Command

+

+ Send a Tetra command envelope through the dashboard server. +

+
+ +
+
+
+ + +
+
+ + +
+
+ +
+ +