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 \nHello 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 @@
Search...
⌘K
@@ -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}
+
(mobileListOpen = !mobileListOpen)}
+ >
+
+
+
+ {/if}
+
goto(`${hostsPath}/create`)}
+ >
+
+
+
+
+
+
+
+
+
+
+ {@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}
+
+
goto(`/projects/${page.params.projectid}/hosts/create`)}
+ >
+
+ Register Host
+
+
+
+
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}
+
+
+ {host.agentUrl ?? 'No agent URL'}
+
+
+
+
+ {#if refreshing}
+
+ {:else}
+
+ {/if}
+ Refresh
+ Refresh host
+
+
+ {#if deleting}
+
+ {:else}
+
+ {/if}
+ Delete
+ Delete host
+
+
+
+
+
+
+{#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.
+
+
+
+
+
+
+
+ Payload JSON
+
+
+
+ {#if actionError}
+
+ {actionError}
+
+ {/if}
+
+
+ {#if dispatching}
+
+ {:else}
+
+ {/if}
+ Dispatch
+
+
+ {#if responseJson}
+
{responseJson}
+ {/if}
+
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/lib/host-detail.ts b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/lib/host-detail.ts
new file mode 100644
index 0000000..2a06c2a
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/lib/host-detail.ts
@@ -0,0 +1,18 @@
+import Activity from '~icons/nucleo/activity';
+import Box from '~icons/nucleo/box';
+import FileText from '~icons/nucleo/file-text';
+import Send from '~icons/nucleo/send';
+import type { IconComponent } from '$lib';
+
+export type HostTab = 'overview' | 'podman' | 'quadlets' | 'dispatch';
+
+export const hostTabs: {
+ id: HostTab;
+ label: string;
+ icon: IconComponent;
+}[] = [
+ { id: 'overview', label: 'Overview', icon: Activity },
+ { id: 'podman', label: 'Podman', icon: Box },
+ { id: 'quadlets', label: 'Quadlets', icon: FileText },
+ { id: 'dispatch', label: 'Dispatch', icon: Send }
+];
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/podman/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/podman/+page.svelte
new file mode 100644
index 0000000..abf50eb
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/podman/+page.svelte
@@ -0,0 +1,280 @@
+
+
+
+
+
+
Podman
+
Inspect and manage containers on this host.
+
+
refreshPodman()}
+ disabled={podmanLoading}
+ >
+ {#if podmanLoading}
+
+ {:else}
+
+ {/if}
+ Refresh
+
+
+
+
+ {#each podmanResources as resource (resource.id)}
+ refreshPodman(resource.id)}
+ disabled={podmanLoading}
+ >
+ {resource.label}
+
+ {/each}
+
+
+ {#if podmanError}
+
+ {podmanError}
+
+ {/if}
+
+
+ {#if podmanLoading && podmanData.length === 0}
+
+
+ Loading Podman {podmanResource}
+
+ {:else if podmanData.length === 0}
+
No Podman {podmanResource} found.
+ {:else}
+
+
+
+
+ {#if podmanResource === 'containers'}
+ Name
+ Image
+ State
+ Status
+ Actions
+ {:else if podmanResource === 'images'}
+ Repository
+ Tag
+ Image ID
+ Size
+ {:else if podmanResource === 'volumes'}
+ Name
+ Driver
+ Mountpoint
+ {:else}
+ Name
+ Driver
+ Interface
+ {/if}
+
+
+
+ {#each podmanData as item, index (podmanRowKey(item, index))}
+ {@const record = asRecord(item)}
+
+ {#if podmanResource === 'containers'}
+ {@const name = containerName(item)}
+
+ {name}
+
+
+ {field(record, ['Image'])}
+
+ {field(record, ['State'])}
+
+ {field(record, ['Status'])}
+
+
+
+ {#each ['start', 'stop', 'restart'] as action (action)}
+
+ runContainerAction(action as 'start' | 'stop' | 'restart', name)}
+ disabled={!!podmanActionName}
+ >
+ {podmanActionName === `${action}:${name}` ? '...' : action}
+
+ {/each}
+ runContainerAction('remove', name)}
+ disabled={!!podmanActionName}
+ >
+ Remove
+
+ openContainer(name)}
+ >
+ Edit
+
+ openContainer(name, 'logs')}
+ >
+ Logs
+
+
+
+ {:else if podmanResource === 'images'}
+
+ {field(record, ['Repository'])}
+
+ {field(record, ['Tag'])}
+
+ {field(record, ['Id', 'ID', 'ImageID'])}
+
+ {field(record, ['Size'])}
+ {:else if podmanResource === 'volumes'}
+ {field(record, ['Name'])}
+ {field(record, ['Driver'])}
+
+ {field(record, ['Mountpoint'])}
+
+ {:else}
+ {field(record, ['Name'])}
+ {field(record, ['Driver'])}
+
+ {field(record, ['NetworkInterface', 'Interface'])}
+
+ {/if}
+
+ {/each}
+
+
+
+ {/if}
+
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/podman/[container]/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/podman/[container]/+page.svelte
new file mode 100644
index 0000000..73139f4
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/podman/[container]/+page.svelte
@@ -0,0 +1,325 @@
+
+
+
+
+
+
Podman Container
+
{containerName}
+
+
+ {#if loading}
+
+ {:else}
+
+ {/if}
+ Refresh
+
+
+
+ {#if actionError}
+
+ {actionError}
+
+ {/if}
+
+ {#if loading && !container}
+
+
+ Loading container
+
+ {:else if container}
+
+
+ Runtime
+
+ {#each [['State', container.state ?? 'Unknown'], ['Status', container.status ?? 'Unknown'], ['Image', container.image ?? 'Unknown'], ['Created', formatDate(container.createdAt)], ['ID', container.id ?? 'Unknown']] as [label, value] (label)}
+
+ {label}
+ {value}
+
+ {/each}
+
+
+ {#if Object.keys(container.labels).length > 0}
+ Labels
+
+ {#each Object.entries(container.labels) as [key, value] (key)}
+
{key}={value}
+ {/each}
+
+ {/if}
+
+
+
+
+
+
Configuration Draft
+
+ Changes here are staged for a future recreate or Quadlet workflow.
+
+
+
{draftSaved ? 'Draft saved' : 'Save draft'}
+
+
+
+
+
+
Environment
+
addListItem('env')}>
+
+ Add
+
+
+ {#each env as value, index (`env-${index}`)}
+
+ (env = updateList(env, index, event.currentTarget.value))}
+ />
+ (env = removeListItem(env, index))}
+ >
+
+
+
+ {/each}
+
+
+
+
+
Volume Binds
+
addListItem('bind')}>
+
+ Add
+
+
+ {#each binds as value, index (`bind-${index}`)}
+
+ (binds = updateList(binds, index, event.currentTarget.value))}
+ />
+ (binds = removeListItem(binds, index))}
+ >
+
+
+
+ {/each}
+
+
+
+
+
Ports
+
addListItem('port')}>
+
+ Add
+
+
+ {#each ports as value, index (`port-${index}`)}
+
+ (ports = updateList(ports, index, event.currentTarget.value))}
+ />
+ (ports = removeListItem(ports, index))}
+ >
+
+
+
+ {/each}
+
+
+
+
+ Generated recreate command
+
+
+
+
+
+
+
+
+
+ Raw Inspect
+
+ {JSON.stringify(
+ container.rawInspect,
+ null,
+ 2
+ )}
+
+
+ {/if}
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/+page.svelte
new file mode 100644
index 0000000..963e6dc
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/+page.svelte
@@ -0,0 +1,179 @@
+
+
+
+
+
+
Quadlets
+
+ Manage Quadlet units and the files they reference.
+
+
+
+
+ {#each ['user', 'system'] as item (item)}
+ {
+ scope = item as ManagedHostQuadletScope;
+ }}
+ >
+ {item}
+
+ {/each}
+
+
+ {#if loading}
+
+ {:else}
+
+ {/if}
+ Refresh
+
+
+ goto(`/projects/${page.params.projectid}/hosts/${host.id}/quadlets/create?scope=${scope}`)}
+ >
+
+ New
+
+
+
+
+ {#if baseDir}
+
+
Units: {baseDir}
+ {#if filesBaseDir}
+
Files: {filesBaseDir}
+ {/if}
+
+ {/if}
+
+ {#if actionError}
+
+ {actionError}
+
+ {/if}
+
+
+ {#if loading && files.length === 0}
+
+
+ Loading Quadlets
+
+ {:else if quadlets.length === 0}
+
No Quadlet units found.
+ {:else}
+
+
+
+
+ Unit
+ Path
+ Actions
+
+
+
+ {#each quadlets as file (file.filename)}
+
+ {file.filename}
+
+ {file.path ?? baseDir ?? 'Unknown'}
+
+
+ editQuadlet(file.filename)}
+ >
+ Edit
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+ {#if companionFiles.length > 0}
+
+ Companion Files
+
+ {#each companionFiles as file (file.filename)}
+
+
{file.filename}
+
+ {file.path ?? filesBaseDir ?? 'Unknown'}
+
+
+ {/each}
+
+
+ {/if}
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/[quadlet]/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/[quadlet]/+page.svelte
new file mode 100644
index 0000000..b79d1cc
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/[quadlet]/+page.svelte
@@ -0,0 +1,77 @@
+
+
+
+
+
{filename}
+
+ Edit the Quadlet specification, parameters, and companion files.
+
+
+
+ {#if actionError}
+
+ {actionError}
+
+ {/if}
+
+ {#if loading && !detail}
+
+
+ Loading Quadlet
+
+ {:else if detail}
+
+
+
+ {/if}
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/create/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/create/+page.svelte
new file mode 100644
index 0000000..91b7767
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/create/+page.svelte
@@ -0,0 +1,498 @@
+
+
+
+
+
New Quadlet
+
+ Create a Quadlet unit with companion files in the host's mutable data directory.
+
+
+
+
+
Recipe
+
+ selectRecipe('blank')}
+ >
+
+ Blank Quadlet
+
+ Paste or write a unit from scratch.
+
+
+
+ {#each quadletRecipeOptions as recipe (recipe.id)}
+ selectRecipe(recipe.id)}
+ >
+
+ {recipe.name}
+ {recipe.description}
+
+
+ {/each}
+
+
+
+
+ {#if selectedRecipe === 'blank'}
+ {#key `${selectedRecipe}:${initialScope}`}
+
+ {/key}
+ {:else if recipeDetail}
+
+
+
+
+
App Settings
+
{recipeDetail.recipeName}
+
+
+ {#if selectedRecipe === 'nginx-site'}
+
+ App ID
+ updateNginxOption('appId', event.currentTarget.value)}
+ />
+
+
+
+ Site title
+ updateNginxOption('siteTitle', event.currentTarget.value)}
+ />
+
+
+
+ {:else if selectedRecipe === 'nextcloud'}
+
+ App ID
+ updateNextcloudOption('appId', event.currentTarget.value)}
+ />
+
+
+
+ Domain
+ updateNextcloudOption('domain', event.currentTarget.value)}
+ />
+
+
+
+
+ Host port
+
+ updateNextcloudOption('hostPort', Number(event.currentTarget.value))}
+ />
+
+
+
+
Public protocol
+
+ updateNextcloudOption('overwriteProtocol', 'https')}
+ >
+ HTTPS
+
+ updateNextcloudOption('overwriteProtocol', 'http')}
+ >
+ HTTP
+
+
+
+
+
+
+
+
+
+
+ Database password
+
+ updateNextcloudOption('databasePassword', event.currentTarget.value)}
+ />
+
+
+
+
+
+ Trusted proxies
+
+ updateNextcloudOption('trustedProxies', event.currentTarget.value)}
+ />
+
+
+
+
+
Redis cache
+
+ Add a Redis container and wire it into Nextcloud.
+
+
+
nextcloudOptions.enableRedis, (checked) =>
+ updateNextcloudOption('enableRedis', checked)}
+ />
+
+ {:else if selectedRecipe === 'compose'}
+
+ App ID
+ updateComposeOption('appId', event.currentTarget.value)}
+ />
+
+
+
+ Compose specification
+
+
+ {#if composeError}
+
+ {composeError}
+
+ {:else}
+
+
+ Services are converted to container Quadlets. Named volumes and networks become
+ managed Quadlet resources, and relative bind paths are rooted under the mutable
+ app bundle directory.
+
+
+ {/if}
+ {/if}
+
+
+
Generated paths
+
+ {recipeDetail.filename}
+
+ {#if recipeDetail.resources.length > 1}
+
+ {recipeDetail.resources.length} Quadlet resources
+
+ {/if}
+ {#if recipeDetail.filesBaseDir}
+
+ {recipeDetail.filesBaseDir}
+
+ {/if}
+
+
+
+ {#if saving}
+
+ {/if}
+ Create app
+
+
+ (showGeneratedConfiguration = !showGeneratedConfiguration)}
+ >
+ {#if showGeneratedConfiguration}
+
+ Hide generated configuration
+ {:else}
+
+ Show generated configuration
+ {/if}
+
+
+
+ {#if showGeneratedConfiguration}
+
+
+
Generated Configuration
+
+ Review the generated units and files before creating the app.
+
+
+
+
+ {#each recipeDetail.resources as resource, index (resource.filename)}
+
+
+ {resource.filename}
+
+
+
+ {/each}
+
+
+ {#if recipeDetail.files.length > 0}
+
+ {#each recipeDetail.files as file, index (file.filename)}
+
+
+ {file.filename}
+
+
+
+ {/each}
+
+ {/if}
+
+ {/if}
+
+
+ {#if actionError}
+
+ {actionError}
+
+ {/if}
+
+ {:else}
+
+
Select a recipe
+
+ Choose an app recipe above to configure it, or choose Blank Quadlet to write a unit from
+ scratch.
+
+
+ {/if}
+
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/lib/QuadletEditor.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/lib/QuadletEditor.svelte
new file mode 100644
index 0000000..6a2d06d
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/lib/QuadletEditor.svelte
@@ -0,0 +1,278 @@
+
+
+
+
+
+ Scope
+ (saved = false)}
+ >
+ User
+ System
+
+
+
+ Quadlet filename
+ (saved = false)}
+ />
+
+
+
+ {#if detail?.filesBaseDir}
+
Files: {detail.filesBaseDir}
+ {/if}
+
+
+
+
+ Quadlet specification
+
+ {#if saving}
+
+ {/if}
+ {saved ? 'Saved' : 'Save'}
+
+
+
+
+
+ Parameters
+
+ {#if parameters.length === 0}
+
No editable key/value parameters found.
+ {:else}
+ {#each parameters as parameter (parameter.id)}
+
+
+ {parameter.section}.{parameter.key}
+
+ updateParameter(parameter, event.currentTarget.value)}
+ />
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+
+
Companion Files
+
+ Files are written under the mutable bundle data directory, for example
+ /var/lib/tetra/quadlets/demo-web.
+
+
+
+
+ Add file
+
+
+
+
+ {#each files as file (file.id)}
+
+
+
+ Filename
+ updateFile(file.id, 'filename', event.currentTarget.value)}
+ />
+
+
removeFile(file.id)}
+ >
+
+
+
+
+ Contents
+
+
+ {/each}
+
+
+
+ {#if actionError}
+
+ {actionError}
+
+ {/if}
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/lib/recipes.ts b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/lib/recipes.ts
new file mode 100644
index 0000000..9253bbb
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/[id]/quadlets/lib/recipes.ts
@@ -0,0 +1,702 @@
+import type {
+ ManagedHostQuadletDetail,
+ ManagedHostQuadletResource,
+ ManagedHostQuadletScope
+} from '$lib/remote/managed-hosts.remote';
+import { parse as parseYaml } from 'yaml';
+
+export type NginxSiteOptions = {
+ appId: string;
+ siteTitle: string;
+ serverName: string;
+ hostPort: number;
+};
+
+export type NextcloudOptions = {
+ appId: string;
+ domain: string;
+ hostPort: number;
+ adminUser: string;
+ adminPassword: string;
+ databaseName: string;
+ databaseUser: string;
+ databasePassword: string;
+ enableRedis: boolean;
+ phpMemoryLimit: string;
+ uploadLimit: string;
+ trustedProxies: string;
+ overwriteProtocol: 'http' | 'https';
+};
+
+export type ComposeOptions = {
+ appId: string;
+ composeYaml: string;
+};
+
+export type QuadletRecipeOption = {
+ id: 'nginx-site' | 'nextcloud' | 'compose';
+ name: string;
+ description: string;
+ category: string;
+};
+
+export type RecipeDetail = ManagedHostQuadletDetail & {
+ recipeName: string;
+ resources: ManagedHostQuadletResource[];
+};
+
+export const quadletRecipeOptions: QuadletRecipeOption[] = [
+ {
+ id: 'nginx-site',
+ name: 'Nginx static site',
+ description: 'Serve mutable site files with a Quadlet-managed nginx container.',
+ category: 'Web'
+ },
+ {
+ id: 'nextcloud',
+ name: 'Nextcloud',
+ description: 'Run Nextcloud with MariaDB, optional Redis, managed volumes, and web defaults.',
+ category: 'Apps'
+ },
+ {
+ id: 'compose',
+ name: 'Compose specification',
+ description: 'Paste a Compose file and generate equivalent Quadlet container resources.',
+ category: 'Import'
+ }
+];
+
+export function buildRecipeDetail(
+ recipeId: QuadletRecipeOption['id'],
+ scope: ManagedHostQuadletScope,
+ options: NginxSiteOptions | NextcloudOptions | ComposeOptions
+): RecipeDetail {
+ switch (recipeId) {
+ case 'nginx-site':
+ return buildNginxSiteRecipe(scope, options as NginxSiteOptions);
+ case 'nextcloud':
+ return buildNextcloudRecipe(scope, options as NextcloudOptions);
+ case 'compose':
+ return buildComposeRecipe(scope, options as ComposeOptions);
+ }
+}
+
+export function defaultNginxSiteOptions(): NginxSiteOptions {
+ return {
+ appId: 'demo-web',
+ siteTitle: 'Demo Web',
+ serverName: '_',
+ hostPort: 8080
+ };
+}
+
+export function defaultNextcloudOptions(): NextcloudOptions {
+ return {
+ appId: 'nextcloud',
+ domain: 'cloud.example.com',
+ hostPort: 8081,
+ adminUser: 'admin',
+ adminPassword: 'change-me-admin-password',
+ databaseName: 'nextcloud',
+ databaseUser: 'nextcloud',
+ databasePassword: 'change-me-db-password',
+ enableRedis: true,
+ phpMemoryLimit: '512M',
+ uploadLimit: '2G',
+ trustedProxies: '',
+ overwriteProtocol: 'https'
+ };
+}
+
+export function defaultComposeOptions(): ComposeOptions {
+ return {
+ appId: 'compose-app',
+ composeYaml: `services:
+ web:
+ image: docker.io/library/nginx:alpine
+ ports:
+ - "8080:80"
+ volumes:
+ - ./html:/usr/share/nginx/html:ro
+ restart: unless-stopped
+`
+ };
+}
+
+export function validateComposeOptions(options: ComposeOptions): string {
+ try {
+ const compose = parseCompose(options.composeYaml);
+ if (compose.error) return compose.error;
+ return '';
+ } catch (err) {
+ return err instanceof Error ? err.message : 'Compose YAML could not be parsed.';
+ }
+}
+
+function buildNginxSiteRecipe(
+ scope: ManagedHostQuadletScope,
+ options: NginxSiteOptions
+): RecipeDetail {
+ const appId = normalizeAppId(options.appId, 'demo-web');
+ const siteTitle = options.siteTitle.trim() || 'Demo Web';
+ const serverName = options.serverName.trim() || '_';
+ const hostPort = clampPort(options.hostPort, 8080);
+ const filesBaseDir = bundleDir(scope, appId);
+ const contents = `[Unit]
+Description=${siteTitle} web site
+
+[Container]
+ContainerName=${appId}
+Image=docker.io/library/nginx:alpine
+PublishPort=${hostPort}:80
+Volume=${filesBaseDir}:/usr/share/nginx/html:ro
+Volume=${filesBaseDir}/default.conf:/etc/nginx/conf.d/default.conf:ro
+
+[Service]
+Restart=always
+
+[Install]
+WantedBy=default.target
+`;
+ const filename = `${appId}.container`;
+ const resources = [{ filename, contents }];
+
+ return {
+ scope,
+ recipeName: 'Nginx static site',
+ baseDir: quadletBaseDir(scope),
+ filesBaseDir,
+ filename,
+ contents,
+ resources,
+ files: [
+ {
+ filename: 'index.html',
+ contents:
+ `\n${escapeHtml(siteTitle)} \n${escapeHtml(siteTitle)} \nThis page is served from a Tetra-managed Quadlet companion file.
\n`
+ },
+ {
+ filename: 'default.conf',
+ contents:
+ `server {\n listen 80;\n server_name ${serverName};\n root /usr/share/nginx/html;\n index index.html;\n}\n`
+ }
+ ]
+ };
+}
+
+function buildComposeRecipe(
+ scope: ManagedHostQuadletScope,
+ options: ComposeOptions
+): RecipeDetail {
+ const appId = normalizeAppId(options.appId, 'compose-app');
+ const filesBaseDir = bundleDir(scope, appId);
+ const parsed = parseCompose(options.composeYaml);
+ if (parsed.error || !parsed.compose) {
+ const filename = `${appId}-compose.container`;
+ return {
+ scope,
+ recipeName: 'Compose specification',
+ baseDir: quadletBaseDir(scope),
+ filesBaseDir,
+ filename,
+ contents: '',
+ resources: [],
+ files: [{ filename: 'compose.yaml', contents: options.composeYaml.trimEnd() + '\n' }]
+ };
+ }
+
+ const resources: ManagedHostQuadletResource[] = [];
+ const serviceEntries = Object.entries(parsed.compose.services);
+ const serviceNames = new Set(serviceEntries.map(([name]) => name));
+ const namedVolumes = new Set(Object.keys(parsed.compose.volumes ?? {}));
+ const namedNetworks = new Set(Object.keys(parsed.compose.networks ?? {}));
+ const useDefaultNetwork =
+ serviceEntries.length > 1 && serviceEntries.every(([, service]) => !service.networks);
+
+ for (const [, service] of serviceEntries) {
+ for (const volume of service.volumes ?? []) {
+ const source = volumeSourceName(volume);
+ if (source && !isRelativePath(source) && !isAbsolutePath(source)) namedVolumes.add(source);
+ }
+ for (const network of serviceNetworkNames(service.networks)) {
+ namedNetworks.add(network);
+ }
+ }
+
+ if (useDefaultNetwork) namedNetworks.add('default');
+
+ for (const volume of namedVolumes) {
+ const volumeId = normalizeAppId(volume, 'data');
+ resources.push({
+ filename: `${appId}-${volumeId}.volume`,
+ contents: `[Volume]
+VolumeName=${appId}-${volumeId}
+`
+ });
+ }
+
+ for (const network of namedNetworks) {
+ const networkId = normalizeAppId(network, 'default');
+ resources.push({
+ filename: `${appId}-${networkId}.network`,
+ contents: `[Network]
+NetworkName=${appId}-${networkId}
+Driver=bridge
+`
+ });
+ }
+
+ for (const [name, service] of serviceEntries) {
+ const serviceId = normalizeAppId(name, 'service');
+ const dependencies = serviceDependencyNames(service.depends_on).filter((dependency) =>
+ serviceNames.has(dependency)
+ );
+ const unitDependencies = dependencies
+ .map((dependency) => `${appId}-${normalizeAppId(dependency, 'service')}.service`)
+ .join(' ');
+ const serviceNetworks = serviceNetworkNames(service.networks);
+ const networks = serviceNetworks.length
+ ? serviceNetworks
+ : useDefaultNetwork
+ ? ['default']
+ : [];
+ const restart = serviceRestartPolicy(service.restart);
+ const containerLines = [
+ `ContainerName=${appId}-${serviceId}`,
+ `Image=${service.image}`,
+ ...arrayLines('PublishPort', service.ports?.map(formatPort).filter(Boolean) ?? []),
+ ...arrayLines(
+ 'Volume',
+ (service.volumes ?? []).map((volume) => formatVolume(volume, appId, filesBaseDir)).filter(Boolean)
+ ),
+ ...arrayLines(
+ 'Environment',
+ environmentLines(service.environment).concat(service.env_file?.map((file) => `env_file=${file}`) ?? [])
+ ),
+ ...arrayLines(
+ 'Network',
+ networks.map((network) => `${appId}-${normalizeAppId(network, 'default')}.network`)
+ ),
+ ...arrayLines('Label', labelLines(service.labels)),
+ service.user ? `User=${service.user}` : '',
+ service.working_dir ? `WorkingDir=${service.working_dir}` : '',
+ service.entrypoint ? `Entrypoint=${stringOrList(service.entrypoint)}` : '',
+ service.command ? `Exec=${stringOrList(service.command)}` : ''
+ ].filter(Boolean);
+
+ const contents = `[Unit]
+Description=${appId} ${name}
+${unitDependencies ? `Requires=${unitDependencies}\nAfter=${unitDependencies}\n` : ''}
+[Container]
+${containerLines.join('\n')}
+
+[Service]
+Restart=${restart}
+
+[Install]
+WantedBy=default.target
+`;
+ resources.push({ filename: `${appId}-${serviceId}.container`, contents });
+ }
+
+ const filename = resources.find((resource) => resource.filename.endsWith('.container'))?.filename
+ ?? `${appId}.container`;
+ const contents = resources.find((resource) => resource.filename === filename)?.contents ?? '';
+
+ return {
+ scope,
+ recipeName: 'Compose specification',
+ baseDir: quadletBaseDir(scope),
+ filesBaseDir,
+ filename,
+ contents,
+ resources,
+ files: [{ filename: 'compose.yaml', contents: options.composeYaml.trimEnd() + '\n' }]
+ };
+}
+
+function buildNextcloudRecipe(
+ scope: ManagedHostQuadletScope,
+ options: NextcloudOptions
+): RecipeDetail {
+ const appId = normalizeAppId(options.appId, 'nextcloud');
+ const domain = options.domain.trim() || 'cloud.example.com';
+ const hostPort = clampPort(options.hostPort, 8081);
+ const adminUser = safeEnv(options.adminUser, 'admin');
+ const adminPassword = safeEnv(options.adminPassword, 'change-me-admin-password');
+ const databaseName = safeEnv(options.databaseName, 'nextcloud');
+ const databaseUser = safeEnv(options.databaseUser, 'nextcloud');
+ const databasePassword = safeEnv(options.databasePassword, 'change-me-db-password');
+ const phpMemoryLimit = safeEnv(options.phpMemoryLimit, '512M');
+ const uploadLimit = safeEnv(options.uploadLimit, '2G');
+ const trustedProxies = options.trustedProxies.trim();
+ const overwriteProtocol = options.overwriteProtocol === 'http' ? 'http' : 'https';
+
+ const resources: ManagedHostQuadletResource[] = [
+ {
+ filename: `${appId}-net.network`,
+ contents: `[Network]
+NetworkName=${appId}-net
+Driver=bridge
+`
+ },
+ {
+ filename: `${appId}-data.volume`,
+ contents: `[Volume]
+VolumeName=${appId}-data
+`
+ },
+ {
+ filename: `${appId}-db.volume`,
+ contents: `[Volume]
+VolumeName=${appId}-db
+`
+ },
+ {
+ filename: `${appId}-db.container`,
+ contents: `[Unit]
+Description=Nextcloud database
+
+[Container]
+ContainerName=${appId}-db
+Image=docker.io/library/mariadb:11
+Network=${appId}-net.network
+Volume=${appId}-db.volume:/var/lib/mysql
+Environment=MARIADB_DATABASE=${databaseName}
+Environment=MARIADB_USER=${databaseUser}
+Environment=MARIADB_PASSWORD=${databasePassword}
+Environment=MARIADB_ROOT_PASSWORD=${databasePassword}
+
+[Service]
+Restart=always
+
+[Install]
+WantedBy=default.target
+`
+ }
+ ];
+
+ if (options.enableRedis) {
+ resources.push({
+ filename: `${appId}-redis.container`,
+ contents: `[Unit]
+Description=Nextcloud Redis cache
+
+[Container]
+ContainerName=${appId}-redis
+Image=docker.io/library/redis:7-alpine
+Network=${appId}-net.network
+
+[Service]
+Restart=always
+
+[Install]
+WantedBy=default.target
+`
+ });
+ }
+
+ const appContents = `[Unit]
+Description=Nextcloud application
+Requires=${appId}-db.service
+After=${appId}-db.service
+${options.enableRedis ? `Wants=${appId}-redis.service\nAfter=${appId}-redis.service\n` : ''}
+[Container]
+ContainerName=${appId}-app
+Image=docker.io/library/nextcloud:latest
+Network=${appId}-net.network
+Volume=${appId}-data.volume:/var/www/html
+Environment=NEXTCLOUD_TRUSTED_DOMAINS=${domain}
+Environment=OVERWRITEHOST=${domain}
+Environment=OVERWRITEPROTOCOL=${overwriteProtocol}
+Environment=MYSQL_HOST=${appId}-db
+Environment=MYSQL_DATABASE=${databaseName}
+Environment=MYSQL_USER=${databaseUser}
+Environment=MYSQL_PASSWORD=${databasePassword}
+Environment=NEXTCLOUD_ADMIN_USER=${adminUser}
+Environment=NEXTCLOUD_ADMIN_PASSWORD=${adminPassword}
+Environment=PHP_MEMORY_LIMIT=${phpMemoryLimit}
+Environment=PHP_UPLOAD_LIMIT=${uploadLimit}
+${trustedProxies ? `Environment=TRUSTED_PROXIES=${trustedProxies}\n` : ''}${options.enableRedis ? `Environment=REDIS_HOST=${appId}-redis\n` : ''}PublishPort=${hostPort}:80
+
+[Service]
+Restart=always
+
+[Install]
+WantedBy=default.target
+`;
+ const filename = `${appId}-app.container`;
+ resources.push({ filename, contents: appContents });
+
+ return {
+ scope,
+ recipeName: 'Nextcloud',
+ baseDir: quadletBaseDir(scope),
+ filesBaseDir: null,
+ filename,
+ contents: appContents,
+ resources,
+ files: []
+ };
+}
+
+type ComposeSpec = {
+ services: Record;
+ volumes?: Record;
+ networks?: Record;
+};
+
+type ComposeService = {
+ image?: string;
+ build?: unknown;
+ command?: string | string[];
+ entrypoint?: string | string[];
+ environment?: Record | string[];
+ env_file?: string[];
+ ports?: ComposePort[];
+ volumes?: ComposeVolume[];
+ networks?: string[] | Record;
+ depends_on?: string[] | Record;
+ labels?: Record | string[];
+ restart?: string;
+ user?: string;
+ working_dir?: string;
+};
+
+type ComposePort =
+ | string
+ | {
+ target?: number | string;
+ published?: number | string;
+ protocol?: string;
+ };
+
+type ComposeVolume =
+ | string
+ | {
+ type?: string;
+ source?: string;
+ target?: string;
+ read_only?: boolean;
+ };
+
+function parseCompose(text: string): { compose?: ComposeSpec; error: string } {
+ if (!text.trim()) return { error: 'Paste a Compose specification.' };
+ const value = parseYaml(text) as unknown;
+ if (!isRecord(value)) return { error: 'Compose YAML must be a mapping.' };
+ if (!isRecord(value.services) || Object.keys(value.services).length === 0) {
+ return { error: 'Compose YAML must define at least one service.' };
+ }
+
+ const services: Record = {};
+ for (const [name, service] of Object.entries(value.services)) {
+ if (!isRecord(service)) return { error: `Service "${name}" must be a mapping.` };
+ if (typeof service.image !== 'string' || !service.image.trim()) {
+ return {
+ error: `Service "${name}" needs an image. Compose build-only services are not converted yet.`
+ };
+ }
+ services[name] = {
+ image: service.image.trim(),
+ build: service.build,
+ command: stringOrStringArray(service.command),
+ entrypoint: stringOrStringArray(service.entrypoint),
+ environment: environmentValue(service.environment),
+ env_file: stringArray(service.env_file),
+ ports: portArray(service.ports),
+ volumes: volumeArray(service.volumes),
+ networks: networksValue(service.networks),
+ depends_on: dependsOnValue(service.depends_on),
+ labels: labelsValue(service.labels),
+ restart: typeof service.restart === 'string' ? service.restart : undefined,
+ user: typeof service.user === 'string' ? service.user : undefined,
+ working_dir: typeof service.working_dir === 'string' ? service.working_dir : undefined
+ };
+ }
+
+ return {
+ error: '',
+ compose: {
+ services,
+ volumes: isRecord(value.volumes) ? value.volumes : {},
+ networks: isRecord(value.networks) ? value.networks : {}
+ }
+ };
+}
+
+function formatPort(port: ComposePort) {
+ if (typeof port === 'string') return port;
+ const target = port.target ? String(port.target) : '';
+ const published = port.published ? String(port.published) : '';
+ if (!target) return '';
+ const protocol = port.protocol && port.protocol !== 'tcp' ? `/${port.protocol}` : '';
+ return `${published ? `${published}:` : ''}${target}${protocol}`;
+}
+
+function formatVolume(volume: ComposeVolume, appId: string, filesBaseDir: string) {
+ if (typeof volume === 'string') {
+ const parts = volume.split(':');
+ if (parts.length < 2) return volume;
+ const [source, target, ...rest] = parts;
+ return `${formatVolumeSource(source, appId, filesBaseDir)}:${target}${rest.length ? `:${rest.join(':')}` : ''}`;
+ }
+
+ if (!volume.target) return '';
+ const source = volume.source ? formatVolumeSource(volume.source, appId, filesBaseDir) : '';
+ const mode = volume.read_only ? ':ro' : '';
+ return source ? `${source}:${volume.target}${mode}` : volume.target;
+}
+
+function formatVolumeSource(source: string, appId: string, filesBaseDir: string) {
+ if (isRelativePath(source)) return `${filesBaseDir}/${source.replace(/^\.?\//, '')}`;
+ if (isAbsolutePath(source)) return source;
+ return `${appId}-${normalizeAppId(source, 'data')}.volume`;
+}
+
+function volumeSourceName(volume: ComposeVolume) {
+ if (typeof volume === 'string') {
+ const [source] = volume.split(':');
+ return source;
+ }
+ return volume.source;
+}
+
+function environmentLines(environment: ComposeService['environment']) {
+ if (Array.isArray(environment)) return environment;
+ if (!environment) return [];
+ return Object.entries(environment).map(([key, value]) => `${key}=${value ?? ''}`);
+}
+
+function labelLines(labels: ComposeService['labels']) {
+ if (Array.isArray(labels)) return labels;
+ if (!labels) return [];
+ return Object.entries(labels).map(([key, value]) => `${key}=${value ?? ''}`);
+}
+
+function serviceNetworkNames(networks: ComposeService['networks']) {
+ if (Array.isArray(networks)) return networks;
+ if (isRecord(networks)) return Object.keys(networks);
+ return [];
+}
+
+function serviceDependencyNames(dependsOn: ComposeService['depends_on']) {
+ if (Array.isArray(dependsOn)) return dependsOn;
+ if (isRecord(dependsOn)) return Object.keys(dependsOn);
+ return [];
+}
+
+function serviceRestartPolicy(restart: string | undefined) {
+ switch (restart) {
+ case 'no':
+ return 'no';
+ case 'on-failure':
+ return 'on-failure';
+ case 'always':
+ case 'unless-stopped':
+ default:
+ return 'always';
+ }
+}
+
+function arrayLines(key: string, values: string[]) {
+ return values.filter(Boolean).map((value) => `${key}=${value}`);
+}
+
+function stringOrList(value: string | string[]) {
+ return Array.isArray(value) ? value.join(' ') : value;
+}
+
+function stringOrStringArray(value: unknown) {
+ if (typeof value === 'string') return value;
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
+ return undefined;
+}
+
+function stringArray(value: unknown) {
+ if (typeof value === 'string') return [value];
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
+ return undefined;
+}
+
+function portArray(value: unknown) {
+ if (!Array.isArray(value)) return undefined;
+ return value.filter((item): item is ComposePort => typeof item === 'string' || isRecord(item));
+}
+
+function volumeArray(value: unknown) {
+ if (!Array.isArray(value)) return undefined;
+ return value.filter((item): item is ComposeVolume => typeof item === 'string' || isRecord(item));
+}
+
+function environmentValue(value: unknown) {
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
+ if (isRecord(value)) return value;
+ return undefined;
+}
+
+function labelsValue(value: unknown) {
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
+ if (isRecord(value)) return value;
+ return undefined;
+}
+
+function networksValue(value: unknown) {
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
+ if (isRecord(value)) return value;
+ return undefined;
+}
+
+function dependsOnValue(value: unknown) {
+ if (Array.isArray(value) && value.every((item) => typeof item === 'string')) return value;
+ if (isRecord(value)) return value;
+ return undefined;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function quadletBaseDir(scope: ManagedHostQuadletScope) {
+ return scope === 'system' ? '/etc/containers/systemd' : '/home/a11y/.config/containers/systemd';
+}
+
+function bundleDir(scope: ManagedHostQuadletScope, appId: string) {
+ return scope === 'system'
+ ? `/var/lib/tetra/quadlets/${appId}`
+ : `/home/a11y/.local/share/tetra/quadlets/${appId}`;
+}
+
+function normalizeAppId(value: string, fallback: string) {
+ return (
+ value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9_.-]+/g, '-')
+ .replace(/^-+|-+$/g, '') || fallback
+ );
+}
+
+function clampPort(value: number, fallback: number) {
+ const port = Number.isFinite(value) ? Math.trunc(value) : fallback;
+ return Math.max(1, Math.min(65535, port));
+}
+
+function safeEnv(value: string, fallback: string) {
+ return value.trim().replace(/\s+/g, '_') || fallback;
+}
+
+function isRelativePath(value: string) {
+ return value.startsWith('./') || value.startsWith('../');
+}
+
+function isAbsolutePath(value: string) {
+ return value.startsWith('/');
+}
+
+function escapeHtml(value: string) {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"');
+}
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/create/+page.server.ts b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/create/+page.server.ts
new file mode 100644
index 0000000..cbed9d9
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/create/+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/create/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/create/+page.svelte
new file mode 100644
index 0000000..3899d02
--- /dev/null
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/hosts/create/+page.svelte
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
Register Host
+
+
+ Register a Tetra agent to inspect host capabilities and dispatch commands.
+
+
+
+
+
diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/settings/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/settings/+page.svelte
index 7e5aaef..9a49615 100644
--- a/apps/dashboard/src/routes/(app)/projects/[projectid]/settings/+page.svelte
+++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/settings/+page.svelte
@@ -324,7 +324,7 @@
Delete Project
-
+
This will permanently delete the project and all its resources. Type the project name
to confirm.
diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte
index 3fcfe98..75be3f3 100644
--- a/apps/dashboard/src/routes/+layout.svelte
+++ b/apps/dashboard/src/routes/+layout.svelte
@@ -3,13 +3,18 @@
import NavigationProgress from '$lib/components/navigation-progress.svelte';
import { onMount } from 'svelte';
import { ModeWatcher } from 'mode-watcher';
+ import { dashboardBrand } from '$lib/branding';
let { children } = $props();
onMount(async () => {
+ document.documentElement.dataset.brand = dashboardBrand.id;
+
+ if (!dashboardBrand.plausibleDomain) return;
+
const { init } = await import('@plausible-analytics/tracker');
init({
- domain: 'dash.fyrastack.com',
+ domain: dashboardBrand.plausibleDomain,
endpoint: 'https://plausible.fyralabs.com/api/event',
outboundLinks: true
});
@@ -17,10 +22,13 @@
-
-
-
-
+
+
+
+
+
diff --git a/apps/dashboard/src/routes/accept-invitation/[invitationId]/+page.svelte b/apps/dashboard/src/routes/accept-invitation/[invitationId]/+page.svelte
index 55f012c..9d442bb 100644
--- a/apps/dashboard/src/routes/accept-invitation/[invitationId]/+page.svelte
+++ b/apps/dashboard/src/routes/accept-invitation/[invitationId]/+page.svelte
@@ -5,6 +5,7 @@
import AlertCircle from '~icons/nucleo/alert-circle';
import { isProjectRole, projectRoleLabels } from '$lib/auth/organization-permissions';
import type { PageData } from './$types';
+ import { dashboardBrand, pageTitle } from '$lib/branding';
let { data }: { data: PageData } = $props();
let error = $state('');
@@ -24,7 +25,9 @@
accepting = false;
return;
}
- await goto(`/projects/${res.member.organizationId}/servers`, { invalidateAll: true });
+ await goto(`/projects/${res.member.organizationId}/${dashboardBrand.defaultProjectPath}`, {
+ invalidateAll: true
+ });
}
async function decline() {
@@ -50,14 +53,16 @@
- Invitation / Stack
+ {pageTitle('Invitation')}
-
-
Stack
+
+
+ {dashboardBrand.name}
+
{#if data.error}
diff --git a/apps/dashboard/src/routes/layout.css b/apps/dashboard/src/routes/layout.css
index 92f7588..0615d0c 100644
--- a/apps/dashboard/src/routes/layout.css
+++ b/apps/dashboard/src/routes/layout.css
@@ -69,6 +69,66 @@
--ring: var(--gray-500);
}
+:root[data-brand='ultramarine'] {
+ --gray-50: #fefefe;
+ --gray-100: #e6e7e7;
+ --gray-200: #c8cbd8;
+ --gray-300: #ceced0;
+ --gray-400: #b5b6b8;
+ --gray-500: #9d9da0;
+ --gray-600: #6b6c71;
+ --gray-700: #2d3146;
+ --gray-800: #202331;
+ --gray-900: #151720;
+ --gray-950: #0d0e14;
+ --um-accent-50: #c5cae9;
+ --um-accent-100: #a7b6d2;
+ --um-accent-200: #8f94be;
+ --um-accent-300: #7f83c4;
+ --um-accent-400: #6e76b3;
+ --um-accent-500: #505ed6;
+ --um-accent-600: #3a47b1;
+ --um-accent-700: #263096;
+ --um-accent-800: #1a2573;
+ --background: var(--gray-50);
+ --foreground: var(--gray-900);
+ --card: var(--gray-50);
+ --card-foreground: var(--gray-900);
+ --popover: var(--gray-50);
+ --popover-foreground: var(--gray-900);
+ --primary: var(--um-accent-600);
+ --primary-foreground: var(--gray-50);
+ --secondary: var(--gray-100);
+ --secondary-foreground: var(--gray-900);
+ --muted: var(--gray-100);
+ --muted-foreground: var(--gray-600);
+ --accent: var(--um-accent-500);
+ --accent-foreground: var(--gray-50);
+ --border: var(--gray-200);
+ --input: var(--gray-200);
+ --ring: var(--um-accent-400);
+}
+
+.dark:root[data-brand='ultramarine'] {
+ --background: var(--gray-900);
+ --foreground: var(--gray-50);
+ --card: var(--gray-900);
+ --card-foreground: var(--gray-50);
+ --popover: var(--gray-900);
+ --popover-foreground: var(--gray-50);
+ --primary: var(--um-accent-500);
+ --primary-foreground: var(--gray-50);
+ --secondary: var(--gray-800);
+ --secondary-foreground: var(--gray-100);
+ --muted: var(--gray-800);
+ --muted-foreground: var(--gray-400);
+ --accent: var(--um-accent-400);
+ --accent-foreground: var(--gray-50);
+ --border: var(--gray-800);
+ --input: var(--gray-800);
+ --ring: var(--um-accent-500);
+}
+
@theme inline {
--color-gray-50: var(--gray-50);
--color-gray-100: var(--gray-100);
@@ -88,6 +148,15 @@
--color-red-600: var(--red-600);
--color-red-700: var(--red-700);
--color-red-950: var(--red-950);
+ --color-accent-50: var(--um-accent-50);
+ --color-accent-100: var(--um-accent-100);
+ --color-accent-200: var(--um-accent-200);
+ --color-accent-300: var(--um-accent-300);
+ --color-accent-400: var(--um-accent-400);
+ --color-accent-500: var(--um-accent-500);
+ --color-accent-600: var(--um-accent-600);
+ --color-accent-700: var(--um-accent-700);
+ --color-accent-800: var(--um-accent-800);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
diff --git a/apps/dashboard/src/routes/login/+page.svelte b/apps/dashboard/src/routes/login/+page.svelte
index c1fa8fd..f131e94 100644
--- a/apps/dashboard/src/routes/login/+page.svelte
+++ b/apps/dashboard/src/routes/login/+page.svelte
@@ -11,6 +11,7 @@
import Fingerprint from '~icons/nucleo/fingerprint';
import SiGithub from '@icons-pack/svelte-simple-icons/icons/SiGithub';
import type { PageData } from './$types';
+ import { dashboardBrand, pageTitle } from '$lib/branding';
type SignInDataWithTwoFactor = {
twoFactorRedirect?: boolean;
twoFactorMethods?: string[] | null;
@@ -103,14 +104,16 @@
- Sign in / Stack
+ {pageTitle('Sign in')}
-
-
Stack
+
+
+ {dashboardBrand.name}
+
@@ -129,7 +132,7 @@
-
+
Email verified! Please sign in.
{/if}
diff --git a/apps/dashboard/src/routes/login/two-factor/passkey/+page.svelte b/apps/dashboard/src/routes/login/two-factor/passkey/+page.svelte
index a7fd586..80867a3 100644
--- a/apps/dashboard/src/routes/login/two-factor/passkey/+page.svelte
+++ b/apps/dashboard/src/routes/login/two-factor/passkey/+page.svelte
@@ -7,6 +7,7 @@
import Fingerprint from '~icons/nucleo/fingerprint';
import { onMount } from 'svelte';
import type { PageData } from './$types';
+ import { dashboardBrand, pageTitle } from '$lib/branding';
let { data }: { data: PageData } = $props();
const redirectTo: string = $derived(data.redirectTo ?? '/');
@@ -44,20 +45,22 @@
- Verify with Passkey / Stack
+ {pageTitle('Verify with Passkey')}
-
-
Stack
+
+
+ {dashboardBrand.name}
+
-
+
Verify with Passkey
@@ -85,14 +88,14 @@
{#if data.canUseTotp}
- Use authenticator app instead
{/if}
- Need another method? Back to sign in
diff --git a/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte b/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte
index ba57e57..327bd16 100644
--- a/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte
+++ b/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte
@@ -7,6 +7,7 @@
import AlertCircle from '~icons/nucleo/alert-circle';
import ShieldCheck from '~icons/nucleo/shield-check';
import type { PageData } from './$types';
+ import { dashboardBrand, pageTitle } from '$lib/branding';
let { data }: { data: PageData } = $props();
const redirectTo: string = $derived(data.redirectTo ?? '/');
@@ -37,20 +38,22 @@
- Two-Factor Authentication / Stack
+ {pageTitle('Two-Factor Authentication')}
-
-
Stack
+
+
+ {dashboardBrand.name}
+
-
+
Two-Factor Authentication
diff --git a/apps/dashboard/src/routes/register/+page.svelte b/apps/dashboard/src/routes/register/+page.svelte
index df0d094..c0d5091 100644
--- a/apps/dashboard/src/routes/register/+page.svelte
+++ b/apps/dashboard/src/routes/register/+page.svelte
@@ -9,6 +9,7 @@
import EyeOff from '~icons/nucleo/eye-off';
import SiGithub from '@icons-pack/svelte-simple-icons/icons/SiGithub';
import type { PageData } from './$types';
+ import { dashboardBrand, pageTitle } from '$lib/branding';
let { data }: { data: PageData } = $props();
const redirectTo = $derived(data.redirectTo ?? '/');
@@ -78,14 +79,16 @@
- Register / Stack
+ {pageTitle('Register')}
-
-
Stack
+
+
+ {dashboardBrand.name}
+
@@ -104,7 +107,7 @@
-
+
Check your email to verify your account.
{/if}
diff --git a/apps/dashboard/static/ultramarine-apple-touch-icon.png b/apps/dashboard/static/ultramarine-apple-touch-icon.png
new file mode 100644
index 0000000..c217e89
Binary files /dev/null and b/apps/dashboard/static/ultramarine-apple-touch-icon.png differ
diff --git a/apps/dashboard/static/ultramarine-favicon-16x16.png b/apps/dashboard/static/ultramarine-favicon-16x16.png
new file mode 100644
index 0000000..5f32a09
Binary files /dev/null and b/apps/dashboard/static/ultramarine-favicon-16x16.png differ
diff --git a/apps/dashboard/static/ultramarine-favicon-32x32.png b/apps/dashboard/static/ultramarine-favicon-32x32.png
new file mode 100644
index 0000000..39fca7d
Binary files /dev/null and b/apps/dashboard/static/ultramarine-favicon-32x32.png differ
diff --git a/apps/dashboard/static/ultramarine-favicon.ico b/apps/dashboard/static/ultramarine-favicon.ico
new file mode 100644
index 0000000..76f30b2
Binary files /dev/null and b/apps/dashboard/static/ultramarine-favicon.ico differ
diff --git a/apps/dashboard/static/ultramarine-logo.svg b/apps/dashboard/static/ultramarine-logo.svg
new file mode 100644
index 0000000..3922c1c
--- /dev/null
+++ b/apps/dashboard/static/ultramarine-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/dashboard/tests/accessibility.test.ts b/apps/dashboard/tests/accessibility.test.ts
index a25b51a..40376c4 100644
--- a/apps/dashboard/tests/accessibility.test.ts
+++ b/apps/dashboard/tests/accessibility.test.ts
@@ -1,5 +1,5 @@
import { styleText } from 'node:util';
-import { expect, localURL, pages, test } from './accessibility';
+import { activeBrand, expect, localURL, pages, test } from './accessibility';
function formatImpact(impact: string | null | undefined) {
if (!impact) {
@@ -20,7 +20,7 @@ const modes = ['dark', 'light'] as const;
for (const mode of modes) {
for (const { label, path } of pages) {
- test(`Testing for accessibility violations on ${label} in ${mode} mode.`, async ({
+ test(`Testing for accessibility violations on ${label} with ${activeBrand} branding in ${mode} mode.`, async ({
page,
makeAxeBuilder
}) => {
@@ -30,7 +30,7 @@ for (const mode of modes) {
await page.goto(`${localURL}${path}`, { waitUntil: 'networkidle' });
const { violations } = await makeAxeBuilder().analyze();
- const reportMessage = `Found ${violations.length} accessibility violations on ${label} in ${mode} mode.`;
+ const reportMessage = `Found ${violations.length} accessibility violations on ${label} with ${activeBrand} branding in ${mode} mode.`;
if (violations.length === 0) {
expect(violations, reportMessage).toHaveLength(0);
diff --git a/apps/dashboard/tests/accessibility.ts b/apps/dashboard/tests/accessibility.ts
index a0fa81a..b8061b4 100644
--- a/apps/dashboard/tests/accessibility.ts
+++ b/apps/dashboard/tests/accessibility.ts
@@ -7,6 +7,9 @@ type AxeFixture = {
export const localURL = 'http://127.0.0.1:4173';
+export const activeBrand =
+ process.env['PUBLIC_DASHBOARD_BRAND']?.toLowerCase() === 'ultramarine' ? 'ultramarine' : 'stack';
+
export const pages = [
{ label: 'login', path: '/login' },
{ label: 'login verified state', path: '/login?verified=1' },
@@ -15,6 +18,23 @@ export const pages = [
{ label: 'project dashboard', path: '/' },
{ label: 'server list', path: '/projects/accessibility-project/servers' },
{ label: 'server detail', path: '/projects/accessibility-project/servers/accessibility-server' },
+ { label: 'host overview', path: '/projects/accessibility-project/hosts' },
+ { label: 'host registration', path: '/projects/accessibility-project/hosts/create' },
+ { label: 'host detail', path: '/projects/accessibility-project/hosts/accessibility-host' },
+ { label: 'host podman', path: '/projects/accessibility-project/hosts/accessibility-host/podman' },
+ {
+ label: 'host podman container',
+ path: '/projects/accessibility-project/hosts/accessibility-host/podman/demo-web'
+ },
+ { label: 'host quadlets', path: '/projects/accessibility-project/hosts/accessibility-host/quadlets' },
+ {
+ label: 'host quadlet detail',
+ path: '/projects/accessibility-project/hosts/accessibility-host/quadlets/demo-web.container'
+ },
+ {
+ label: 'host quadlet create',
+ path: '/projects/accessibility-project/hosts/accessibility-host/quadlets/create'
+ },
{ label: 'project settings', path: '/projects/accessibility-project/settings' }
];
diff --git a/apps/dashboard/tests/run-accessibility-brands.mjs b/apps/dashboard/tests/run-accessibility-brands.mjs
new file mode 100644
index 0000000..37b49bd
--- /dev/null
+++ b/apps/dashboard/tests/run-accessibility-brands.mjs
@@ -0,0 +1,25 @@
+import { spawnSync } from 'node:child_process';
+
+const runs = [
+ { label: 'stack', env: {} },
+ { label: 'ultramarine', env: { PUBLIC_DASHBOARD_BRAND: 'ultramarine' } }
+];
+
+let failed = false;
+
+for (const run of runs) {
+ console.log(`\nRunning accessibility checks for ${run.label} branding...\n`);
+ const result = spawnSync('pnpm', ['exec', 'playwright', 'test'], {
+ stdio: 'inherit',
+ env: { ...process.env, ...run.env }
+ });
+
+ if (result.status !== 0) {
+ failed = true;
+ console.error(`\nAccessibility checks failed for ${run.label} branding.\n`);
+ }
+}
+
+if (failed) {
+ process.exit(1);
+}
diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts
index 55fc825..66c8e48 100644
--- a/apps/dashboard/vite.config.ts
+++ b/apps/dashboard/vite.config.ts
@@ -18,6 +18,7 @@ export default defineConfig(({ mode }) => {
}
return {
+ envPrefix: ['VITE_', 'PUBLIC_'],
plugins: [
tailwindcss(),
sveltekit(),
diff --git a/dev/README.md b/dev/README.md
index 00e4653..1b2f448 100644
--- a/dev/README.md
+++ b/dev/README.md
@@ -29,6 +29,67 @@ this is needed cause the 3 proxmox nodes will make a lot of inotify instances.
`sudo wg-quick up dev/fyra-wg.conf` / `sudo wg-quick down dev/fyra-wg.conf`
- PVE web UIs: `https://127.0.0.1:8006`-`8008` (root / fyradev)
+## Fixture UI mode (non-Linux)
+
+Use fixture mode when you only need to test Svelte UI changes and do not want
+to run Postgres, Proxmox, VyOS, or a Tetra/Podman host. This is the easiest path
+on macOS.
+
+Fixture mode is enabled by `ACCESSIBILITY_FIXTURES=1`. It installs a fake
+authenticated admin session, a fake project, one fake VM, one fake managed host,
+managed-host Podman fixture data, and a Quadlet bundle with nginx companion
+files.
+
+The Quadlet fixture mirrors the production storage split: unit files live in
+the Podman Quadlet directory, while companion files live in a mutable data root
+such as `/var/lib/tetra/quadlets/
` for system-scope Quadlets.
+
+From the repo root:
+
+```sh
+CI=true \
+ACCESSIBILITY_FIXTURES=1 \
+CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE=postgres://fixture:fixture@127.0.0.1:5432/fixture \
+pnpm --filter stack-dashboard run dev --host 127.0.0.1 --port 5173
+```
+
+The Hyperdrive connection string only satisfies Cloudflare adapter local
+emulation. Fixture-backed routes do not connect to that database.
+
+Useful fixture URLs:
+
+- Stack brand managed host:
+ `http://127.0.0.1:5173/projects/accessibility-project/hosts/accessibility-host`
+- Managed host Podman tab:
+ `http://127.0.0.1:5173/projects/accessibility-project/hosts/accessibility-host/podman`
+- Managed host Podman container detail:
+ `http://127.0.0.1:5173/projects/accessibility-project/hosts/accessibility-host/podman/demo-web`
+- Managed host Quadlets tab:
+ `http://127.0.0.1:5173/projects/accessibility-project/hosts/accessibility-host/quadlets`
+- Managed host Quadlet detail:
+ `http://127.0.0.1:5173/projects/accessibility-project/hosts/accessibility-host/quadlets/demo-web.container`
+- Managed host Quadlet create from recipe:
+ `http://127.0.0.1:5173/projects/accessibility-project/hosts/accessibility-host/quadlets/create`
+- Managed host raw dispatch tab:
+ `http://127.0.0.1:5173/projects/accessibility-project/hosts/accessibility-host/dispatch`
+- Fixture VM:
+ `http://127.0.0.1:5173/projects/accessibility-project/servers/accessibility-server`
+
+To test Ultramarine branding, add `PUBLIC_DASHBOARD_BRAND=ultramarine` before
+the command:
+
+```sh
+CI=true \
+ACCESSIBILITY_FIXTURES=1 \
+PUBLIC_DASHBOARD_BRAND=ultramarine \
+CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE=postgres://fixture:fixture@127.0.0.1:5432/fixture \
+pnpm --filter stack-dashboard run dev --host 127.0.0.1 --port 5173
+```
+
+Fixture data lives in
+`apps/dashboard/src/lib/server/accessibility-fixtures.ts`. If a UI needs more
+states for local testing, add them there and keep them deterministic.
+
## After a reboot or stack restart
```sh