Compare commits
15
Commits
6a2457315e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc8cf1d509
|
||
|
|
b431ee4030
|
||
|
|
64a43593b2
|
||
|
|
d53c22b641
|
||
|
|
69b6efea9b
|
||
|
|
fcde6f5155
|
||
|
|
58d5b013bc
|
||
|
|
1f9fd08b40
|
||
|
|
a190c896da
|
||
|
|
cd34f7b597
|
||
|
|
9e4b3e5315
|
||
|
|
519ada57f3
|
||
|
|
9036d23e6b
|
||
|
|
46bfecf3e7
|
||
|
|
847f7cb686
|
@@ -0,0 +1,32 @@
|
|||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE "public"."alert_kind" AS ENUM('print_failed', 'print_paused', 'printer_error', 'ai_detection');
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "printer_alerts" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"farm_id" uuid NOT NULL,
|
||||||
|
"printer_id" uuid,
|
||||||
|
"kind" "alert_kind" NOT NULL,
|
||||||
|
"message" text NOT NULL,
|
||||||
|
"confidence" real,
|
||||||
|
"object_key" text,
|
||||||
|
"acknowledged_by" uuid,
|
||||||
|
"acknowledged_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "printer_alerts" ADD CONSTRAINT "printer_alerts_farm_id_farms_id_fk" FOREIGN KEY ("farm_id") REFERENCES "public"."farms"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "printer_alerts" ADD CONSTRAINT "printer_alerts_printer_id_printers_id_fk" FOREIGN KEY ("printer_id") REFERENCES "public"."printers"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "printer_alerts_farm_open_idx" ON "printer_alerts" USING btree ("farm_id","acknowledged_at");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "printers" ADD COLUMN "camera_snapshot" boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "printer_vendor" ADD VALUE 'octoprint';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "enrolment_codes" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"farm_id" uuid NOT NULL,
|
||||||
|
"code_hash" text NOT NULL,
|
||||||
|
"created_by" uuid NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"used_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "enrolment_codes_code_hash_unique" UNIQUE("code_hash")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "org_invitations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"org_id" uuid NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"role" "org_role" NOT NULL,
|
||||||
|
"token_hash" text NOT NULL,
|
||||||
|
"invited_by" uuid NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"accepted_at" timestamp with time zone,
|
||||||
|
"accepted_by" uuid,
|
||||||
|
CONSTRAINT "org_invitations_token_hash_unique" UNIQUE("token_hash")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "org_members" ADD COLUMN "email" text;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "enrolment_codes" ADD CONSTRAINT "enrolment_codes_farm_id_farms_id_fk" FOREIGN KEY ("farm_id") REFERENCES "public"."farms"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "org_invitations" ADD CONSTRAINT "org_invitations_org_id_organisations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organisations"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "enrolment_codes_farm_idx" ON "enrolment_codes" USING btree ("farm_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "org_invitations_org_idx" ON "org_invitations" USING btree ("org_id");
|
||||||
@@ -0,0 +1,841 @@
|
|||||||
|
{
|
||||||
|
"id": "ee6e6f93-7aec-431b-955d-75c485352ea7",
|
||||||
|
"prevId": "e4751490-d3cf-44ca-8807-f7dba03415ea",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.org_members": {
|
||||||
|
"name": "org_members",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"org_id": {
|
||||||
|
"name": "org_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "org_role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'viewer'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"org_members_user_id_idx": {
|
||||||
|
"name": "org_members_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"org_members_org_id_organisations_id_fk": {
|
||||||
|
"name": "org_members_org_id_organisations_id_fk",
|
||||||
|
"tableFrom": "org_members",
|
||||||
|
"tableTo": "organisations",
|
||||||
|
"columnsFrom": [
|
||||||
|
"org_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"org_members_org_id_user_id_pk": {
|
||||||
|
"name": "org_members_org_id_user_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"org_id",
|
||||||
|
"user_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.organisations": {
|
||||||
|
"name": "organisations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.farms": {
|
||||||
|
"name": "farms",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"org_id": {
|
||||||
|
"name": "org_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"edge_proxy_token": {
|
||||||
|
"name": "edge_proxy_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"name": "location",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"farms_org_id_idx": {
|
||||||
|
"name": "farms_org_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "org_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"farms_org_id_organisations_id_fk": {
|
||||||
|
"name": "farms_org_id_organisations_id_fk",
|
||||||
|
"tableFrom": "farms",
|
||||||
|
"tableTo": "organisations",
|
||||||
|
"columnsFrom": [
|
||||||
|
"org_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.printers": {
|
||||||
|
"name": "printers",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"farm_id": {
|
||||||
|
"name": "farm_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"vendor": {
|
||||||
|
"name": "vendor",
|
||||||
|
"type": "printer_vendor",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "printer_status",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'offline'"
|
||||||
|
},
|
||||||
|
"lan_address": {
|
||||||
|
"name": "lan_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"serial_number": {
|
||||||
|
"name": "serial_number",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"gateway_printer_id": {
|
||||||
|
"name": "gateway_printer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"nozzle_temp_c": {
|
||||||
|
"name": "nozzle_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"nozzle_target_temp_c": {
|
||||||
|
"name": "nozzle_target_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"bed_temp_c": {
|
||||||
|
"name": "bed_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"bed_target_temp_c": {
|
||||||
|
"name": "bed_target_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"progress_percent": {
|
||||||
|
"name": "progress_percent",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"current_layer": {
|
||||||
|
"name": "current_layer",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"total_layers": {
|
||||||
|
"name": "total_layers",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"remaining_minutes": {
|
||||||
|
"name": "remaining_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"current_job_name": {
|
||||||
|
"name": "current_job_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"active_alerts": {
|
||||||
|
"name": "active_alerts",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"camera_stream": {
|
||||||
|
"name": "camera_stream",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"vendor_state": {
|
||||||
|
"name": "vendor_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"vendor_error_code": {
|
||||||
|
"name": "vendor_error_code",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"last_seen_at": {
|
||||||
|
"name": "last_seen_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"printers_farm_gateway_printer_id_idx": {
|
||||||
|
"name": "printers_farm_gateway_printer_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "farm_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "gateway_printer_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"printers_farm_id_farms_id_fk": {
|
||||||
|
"name": "printers_farm_id_farms_id_fk",
|
||||||
|
"tableFrom": "printers",
|
||||||
|
"tableTo": "farms",
|
||||||
|
"columnsFrom": [
|
||||||
|
"farm_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.print_jobs": {
|
||||||
|
"name": "print_jobs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"farm_id": {
|
||||||
|
"name": "farm_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"printer_id": {
|
||||||
|
"name": "printer_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"file_name": {
|
||||||
|
"name": "file_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"r2_object_key": {
|
||||||
|
"name": "r2_object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "print_job_status",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'queued'"
|
||||||
|
},
|
||||||
|
"progress_pct": {
|
||||||
|
"name": "progress_pct",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"estimated_time_s": {
|
||||||
|
"name": "estimated_time_s",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"elapsed_time_s": {
|
||||||
|
"name": "elapsed_time_s",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"submitted_by": {
|
||||||
|
"name": "submitted_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"print_jobs_farm_id_farms_id_fk": {
|
||||||
|
"name": "print_jobs_farm_id_farms_id_fk",
|
||||||
|
"tableFrom": "print_jobs",
|
||||||
|
"tableTo": "farms",
|
||||||
|
"columnsFrom": [
|
||||||
|
"farm_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"print_jobs_printer_id_printers_id_fk": {
|
||||||
|
"name": "print_jobs_printer_id_printers_id_fk",
|
||||||
|
"tableFrom": "print_jobs",
|
||||||
|
"tableTo": "printers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"printer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.frame_embeddings": {
|
||||||
|
"name": "frame_embeddings",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"print_job_id": {
|
||||||
|
"name": "print_job_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"printer_id": {
|
||||||
|
"name": "printer_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"r2_object_key": {
|
||||||
|
"name": "r2_object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"embedding": {
|
||||||
|
"name": "embedding",
|
||||||
|
"type": "vector(512)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"captured_at": {
|
||||||
|
"name": "captured_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"frame_embeddings_embedding_ivfflat_idx": {
|
||||||
|
"name": "frame_embeddings_embedding_ivfflat_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "embedding",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last",
|
||||||
|
"opclass": "vector_cosine_ops"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "ivfflat",
|
||||||
|
"with": {
|
||||||
|
"lists": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"frame_embeddings_print_job_id_print_jobs_id_fk": {
|
||||||
|
"name": "frame_embeddings_print_job_id_print_jobs_id_fk",
|
||||||
|
"tableFrom": "frame_embeddings",
|
||||||
|
"tableTo": "print_jobs",
|
||||||
|
"columnsFrom": [
|
||||||
|
"print_job_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"frame_embeddings_printer_id_printers_id_fk": {
|
||||||
|
"name": "frame_embeddings_printer_id_printers_id_fk",
|
||||||
|
"tableFrom": "frame_embeddings",
|
||||||
|
"tableTo": "printers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"printer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.printer_alerts": {
|
||||||
|
"name": "printer_alerts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"farm_id": {
|
||||||
|
"name": "farm_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"printer_id": {
|
||||||
|
"name": "printer_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "alert_kind",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"name": "confidence",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"object_key": {
|
||||||
|
"name": "object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"acknowledged_by": {
|
||||||
|
"name": "acknowledged_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"acknowledged_at": {
|
||||||
|
"name": "acknowledged_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"printer_alerts_farm_open_idx": {
|
||||||
|
"name": "printer_alerts_farm_open_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "farm_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "acknowledged_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"printer_alerts_farm_id_farms_id_fk": {
|
||||||
|
"name": "printer_alerts_farm_id_farms_id_fk",
|
||||||
|
"tableFrom": "printer_alerts",
|
||||||
|
"tableTo": "farms",
|
||||||
|
"columnsFrom": [
|
||||||
|
"farm_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"printer_alerts_printer_id_printers_id_fk": {
|
||||||
|
"name": "printer_alerts_printer_id_printers_id_fk",
|
||||||
|
"tableFrom": "printer_alerts",
|
||||||
|
"tableTo": "printers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"printer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.org_role": {
|
||||||
|
"name": "org_role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"viewer",
|
||||||
|
"operator",
|
||||||
|
"owner"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.printer_status": {
|
||||||
|
"name": "printer_status",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"offline",
|
||||||
|
"idle",
|
||||||
|
"printing",
|
||||||
|
"paused",
|
||||||
|
"error",
|
||||||
|
"maintenance"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.printer_vendor": {
|
||||||
|
"name": "printer_vendor",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"bambu",
|
||||||
|
"prusa",
|
||||||
|
"klipper"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.print_job_status": {
|
||||||
|
"name": "print_job_status",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"queued",
|
||||||
|
"uploading",
|
||||||
|
"printing",
|
||||||
|
"paused",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
"cancelled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.alert_kind": {
|
||||||
|
"name": "alert_kind",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"print_failed",
|
||||||
|
"print_paused",
|
||||||
|
"printer_error",
|
||||||
|
"ai_detection"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,848 @@
|
|||||||
|
{
|
||||||
|
"id": "aef1c8fd-6a0a-48b5-9957-26b3630990f3",
|
||||||
|
"prevId": "ee6e6f93-7aec-431b-955d-75c485352ea7",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.org_members": {
|
||||||
|
"name": "org_members",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"org_id": {
|
||||||
|
"name": "org_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "org_role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'viewer'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"org_members_user_id_idx": {
|
||||||
|
"name": "org_members_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"org_members_org_id_organisations_id_fk": {
|
||||||
|
"name": "org_members_org_id_organisations_id_fk",
|
||||||
|
"tableFrom": "org_members",
|
||||||
|
"tableTo": "organisations",
|
||||||
|
"columnsFrom": [
|
||||||
|
"org_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"org_members_org_id_user_id_pk": {
|
||||||
|
"name": "org_members_org_id_user_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"org_id",
|
||||||
|
"user_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.organisations": {
|
||||||
|
"name": "organisations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.farms": {
|
||||||
|
"name": "farms",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"org_id": {
|
||||||
|
"name": "org_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"edge_proxy_token": {
|
||||||
|
"name": "edge_proxy_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"name": "location",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"farms_org_id_idx": {
|
||||||
|
"name": "farms_org_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "org_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"farms_org_id_organisations_id_fk": {
|
||||||
|
"name": "farms_org_id_organisations_id_fk",
|
||||||
|
"tableFrom": "farms",
|
||||||
|
"tableTo": "organisations",
|
||||||
|
"columnsFrom": [
|
||||||
|
"org_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.printers": {
|
||||||
|
"name": "printers",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"farm_id": {
|
||||||
|
"name": "farm_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"vendor": {
|
||||||
|
"name": "vendor",
|
||||||
|
"type": "printer_vendor",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "printer_status",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'offline'"
|
||||||
|
},
|
||||||
|
"lan_address": {
|
||||||
|
"name": "lan_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"serial_number": {
|
||||||
|
"name": "serial_number",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"gateway_printer_id": {
|
||||||
|
"name": "gateway_printer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"nozzle_temp_c": {
|
||||||
|
"name": "nozzle_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"nozzle_target_temp_c": {
|
||||||
|
"name": "nozzle_target_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"bed_temp_c": {
|
||||||
|
"name": "bed_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"bed_target_temp_c": {
|
||||||
|
"name": "bed_target_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"progress_percent": {
|
||||||
|
"name": "progress_percent",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"current_layer": {
|
||||||
|
"name": "current_layer",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"total_layers": {
|
||||||
|
"name": "total_layers",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"remaining_minutes": {
|
||||||
|
"name": "remaining_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"current_job_name": {
|
||||||
|
"name": "current_job_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"active_alerts": {
|
||||||
|
"name": "active_alerts",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"camera_stream": {
|
||||||
|
"name": "camera_stream",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"camera_snapshot": {
|
||||||
|
"name": "camera_snapshot",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"vendor_state": {
|
||||||
|
"name": "vendor_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"vendor_error_code": {
|
||||||
|
"name": "vendor_error_code",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"last_seen_at": {
|
||||||
|
"name": "last_seen_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"printers_farm_gateway_printer_id_idx": {
|
||||||
|
"name": "printers_farm_gateway_printer_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "farm_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "gateway_printer_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"printers_farm_id_farms_id_fk": {
|
||||||
|
"name": "printers_farm_id_farms_id_fk",
|
||||||
|
"tableFrom": "printers",
|
||||||
|
"tableTo": "farms",
|
||||||
|
"columnsFrom": [
|
||||||
|
"farm_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.print_jobs": {
|
||||||
|
"name": "print_jobs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"farm_id": {
|
||||||
|
"name": "farm_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"printer_id": {
|
||||||
|
"name": "printer_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"file_name": {
|
||||||
|
"name": "file_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"r2_object_key": {
|
||||||
|
"name": "r2_object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "print_job_status",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'queued'"
|
||||||
|
},
|
||||||
|
"progress_pct": {
|
||||||
|
"name": "progress_pct",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"estimated_time_s": {
|
||||||
|
"name": "estimated_time_s",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"elapsed_time_s": {
|
||||||
|
"name": "elapsed_time_s",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"submitted_by": {
|
||||||
|
"name": "submitted_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"print_jobs_farm_id_farms_id_fk": {
|
||||||
|
"name": "print_jobs_farm_id_farms_id_fk",
|
||||||
|
"tableFrom": "print_jobs",
|
||||||
|
"tableTo": "farms",
|
||||||
|
"columnsFrom": [
|
||||||
|
"farm_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"print_jobs_printer_id_printers_id_fk": {
|
||||||
|
"name": "print_jobs_printer_id_printers_id_fk",
|
||||||
|
"tableFrom": "print_jobs",
|
||||||
|
"tableTo": "printers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"printer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.frame_embeddings": {
|
||||||
|
"name": "frame_embeddings",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"print_job_id": {
|
||||||
|
"name": "print_job_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"printer_id": {
|
||||||
|
"name": "printer_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"r2_object_key": {
|
||||||
|
"name": "r2_object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"embedding": {
|
||||||
|
"name": "embedding",
|
||||||
|
"type": "vector(512)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"captured_at": {
|
||||||
|
"name": "captured_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"frame_embeddings_embedding_ivfflat_idx": {
|
||||||
|
"name": "frame_embeddings_embedding_ivfflat_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "embedding",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last",
|
||||||
|
"opclass": "vector_cosine_ops"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "ivfflat",
|
||||||
|
"with": {
|
||||||
|
"lists": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"frame_embeddings_print_job_id_print_jobs_id_fk": {
|
||||||
|
"name": "frame_embeddings_print_job_id_print_jobs_id_fk",
|
||||||
|
"tableFrom": "frame_embeddings",
|
||||||
|
"tableTo": "print_jobs",
|
||||||
|
"columnsFrom": [
|
||||||
|
"print_job_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"frame_embeddings_printer_id_printers_id_fk": {
|
||||||
|
"name": "frame_embeddings_printer_id_printers_id_fk",
|
||||||
|
"tableFrom": "frame_embeddings",
|
||||||
|
"tableTo": "printers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"printer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.printer_alerts": {
|
||||||
|
"name": "printer_alerts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"farm_id": {
|
||||||
|
"name": "farm_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"printer_id": {
|
||||||
|
"name": "printer_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "alert_kind",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"name": "confidence",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"object_key": {
|
||||||
|
"name": "object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"acknowledged_by": {
|
||||||
|
"name": "acknowledged_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"acknowledged_at": {
|
||||||
|
"name": "acknowledged_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"printer_alerts_farm_open_idx": {
|
||||||
|
"name": "printer_alerts_farm_open_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "farm_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "acknowledged_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"printer_alerts_farm_id_farms_id_fk": {
|
||||||
|
"name": "printer_alerts_farm_id_farms_id_fk",
|
||||||
|
"tableFrom": "printer_alerts",
|
||||||
|
"tableTo": "farms",
|
||||||
|
"columnsFrom": [
|
||||||
|
"farm_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"printer_alerts_printer_id_printers_id_fk": {
|
||||||
|
"name": "printer_alerts_printer_id_printers_id_fk",
|
||||||
|
"tableFrom": "printer_alerts",
|
||||||
|
"tableTo": "printers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"printer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.org_role": {
|
||||||
|
"name": "org_role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"viewer",
|
||||||
|
"operator",
|
||||||
|
"owner"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.printer_status": {
|
||||||
|
"name": "printer_status",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"offline",
|
||||||
|
"idle",
|
||||||
|
"printing",
|
||||||
|
"paused",
|
||||||
|
"error",
|
||||||
|
"maintenance"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.printer_vendor": {
|
||||||
|
"name": "printer_vendor",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"bambu",
|
||||||
|
"prusa",
|
||||||
|
"klipper"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.print_job_status": {
|
||||||
|
"name": "print_job_status",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"queued",
|
||||||
|
"uploading",
|
||||||
|
"printing",
|
||||||
|
"paused",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
"cancelled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.alert_kind": {
|
||||||
|
"name": "alert_kind",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"print_failed",
|
||||||
|
"print_paused",
|
||||||
|
"printer_error",
|
||||||
|
"ai_detection"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,849 @@
|
|||||||
|
{
|
||||||
|
"id": "97bbd615-092b-4859-9b89-de9c6295a4f9",
|
||||||
|
"prevId": "aef1c8fd-6a0a-48b5-9957-26b3630990f3",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.org_members": {
|
||||||
|
"name": "org_members",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"org_id": {
|
||||||
|
"name": "org_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "org_role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'viewer'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"org_members_user_id_idx": {
|
||||||
|
"name": "org_members_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"org_members_org_id_organisations_id_fk": {
|
||||||
|
"name": "org_members_org_id_organisations_id_fk",
|
||||||
|
"tableFrom": "org_members",
|
||||||
|
"tableTo": "organisations",
|
||||||
|
"columnsFrom": [
|
||||||
|
"org_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"org_members_org_id_user_id_pk": {
|
||||||
|
"name": "org_members_org_id_user_id_pk",
|
||||||
|
"columns": [
|
||||||
|
"org_id",
|
||||||
|
"user_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.organisations": {
|
||||||
|
"name": "organisations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.farms": {
|
||||||
|
"name": "farms",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"org_id": {
|
||||||
|
"name": "org_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"edge_proxy_token": {
|
||||||
|
"name": "edge_proxy_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"name": "location",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"farms_org_id_idx": {
|
||||||
|
"name": "farms_org_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "org_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"farms_org_id_organisations_id_fk": {
|
||||||
|
"name": "farms_org_id_organisations_id_fk",
|
||||||
|
"tableFrom": "farms",
|
||||||
|
"tableTo": "organisations",
|
||||||
|
"columnsFrom": [
|
||||||
|
"org_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.printers": {
|
||||||
|
"name": "printers",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"farm_id": {
|
||||||
|
"name": "farm_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"vendor": {
|
||||||
|
"name": "vendor",
|
||||||
|
"type": "printer_vendor",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "printer_status",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'offline'"
|
||||||
|
},
|
||||||
|
"lan_address": {
|
||||||
|
"name": "lan_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"serial_number": {
|
||||||
|
"name": "serial_number",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"gateway_printer_id": {
|
||||||
|
"name": "gateway_printer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"nozzle_temp_c": {
|
||||||
|
"name": "nozzle_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"nozzle_target_temp_c": {
|
||||||
|
"name": "nozzle_target_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"bed_temp_c": {
|
||||||
|
"name": "bed_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"bed_target_temp_c": {
|
||||||
|
"name": "bed_target_temp_c",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"progress_percent": {
|
||||||
|
"name": "progress_percent",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"current_layer": {
|
||||||
|
"name": "current_layer",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"total_layers": {
|
||||||
|
"name": "total_layers",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"remaining_minutes": {
|
||||||
|
"name": "remaining_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"current_job_name": {
|
||||||
|
"name": "current_job_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"active_alerts": {
|
||||||
|
"name": "active_alerts",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"camera_stream": {
|
||||||
|
"name": "camera_stream",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"camera_snapshot": {
|
||||||
|
"name": "camera_snapshot",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"vendor_state": {
|
||||||
|
"name": "vendor_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"vendor_error_code": {
|
||||||
|
"name": "vendor_error_code",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"last_seen_at": {
|
||||||
|
"name": "last_seen_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"printers_farm_gateway_printer_id_idx": {
|
||||||
|
"name": "printers_farm_gateway_printer_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "farm_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "gateway_printer_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"printers_farm_id_farms_id_fk": {
|
||||||
|
"name": "printers_farm_id_farms_id_fk",
|
||||||
|
"tableFrom": "printers",
|
||||||
|
"tableTo": "farms",
|
||||||
|
"columnsFrom": [
|
||||||
|
"farm_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.print_jobs": {
|
||||||
|
"name": "print_jobs",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"farm_id": {
|
||||||
|
"name": "farm_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"printer_id": {
|
||||||
|
"name": "printer_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"file_name": {
|
||||||
|
"name": "file_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"r2_object_key": {
|
||||||
|
"name": "r2_object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "print_job_status",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'queued'"
|
||||||
|
},
|
||||||
|
"progress_pct": {
|
||||||
|
"name": "progress_pct",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"estimated_time_s": {
|
||||||
|
"name": "estimated_time_s",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"elapsed_time_s": {
|
||||||
|
"name": "elapsed_time_s",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"submitted_by": {
|
||||||
|
"name": "submitted_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'{}'::jsonb"
|
||||||
|
},
|
||||||
|
"started_at": {
|
||||||
|
"name": "started_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"print_jobs_farm_id_farms_id_fk": {
|
||||||
|
"name": "print_jobs_farm_id_farms_id_fk",
|
||||||
|
"tableFrom": "print_jobs",
|
||||||
|
"tableTo": "farms",
|
||||||
|
"columnsFrom": [
|
||||||
|
"farm_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"print_jobs_printer_id_printers_id_fk": {
|
||||||
|
"name": "print_jobs_printer_id_printers_id_fk",
|
||||||
|
"tableFrom": "print_jobs",
|
||||||
|
"tableTo": "printers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"printer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.frame_embeddings": {
|
||||||
|
"name": "frame_embeddings",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"print_job_id": {
|
||||||
|
"name": "print_job_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"printer_id": {
|
||||||
|
"name": "printer_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"r2_object_key": {
|
||||||
|
"name": "r2_object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"embedding": {
|
||||||
|
"name": "embedding",
|
||||||
|
"type": "vector(512)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"captured_at": {
|
||||||
|
"name": "captured_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"frame_embeddings_embedding_ivfflat_idx": {
|
||||||
|
"name": "frame_embeddings_embedding_ivfflat_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "embedding",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last",
|
||||||
|
"opclass": "vector_cosine_ops"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "ivfflat",
|
||||||
|
"with": {
|
||||||
|
"lists": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"frame_embeddings_print_job_id_print_jobs_id_fk": {
|
||||||
|
"name": "frame_embeddings_print_job_id_print_jobs_id_fk",
|
||||||
|
"tableFrom": "frame_embeddings",
|
||||||
|
"tableTo": "print_jobs",
|
||||||
|
"columnsFrom": [
|
||||||
|
"print_job_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"frame_embeddings_printer_id_printers_id_fk": {
|
||||||
|
"name": "frame_embeddings_printer_id_printers_id_fk",
|
||||||
|
"tableFrom": "frame_embeddings",
|
||||||
|
"tableTo": "printers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"printer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"public.printer_alerts": {
|
||||||
|
"name": "printer_alerts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"farm_id": {
|
||||||
|
"name": "farm_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"printer_id": {
|
||||||
|
"name": "printer_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"name": "kind",
|
||||||
|
"type": "alert_kind",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"name": "confidence",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"object_key": {
|
||||||
|
"name": "object_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"acknowledged_by": {
|
||||||
|
"name": "acknowledged_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"acknowledged_at": {
|
||||||
|
"name": "acknowledged_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"printer_alerts_farm_open_idx": {
|
||||||
|
"name": "printer_alerts_farm_open_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "farm_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "acknowledged_at",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"printer_alerts_farm_id_farms_id_fk": {
|
||||||
|
"name": "printer_alerts_farm_id_farms_id_fk",
|
||||||
|
"tableFrom": "printer_alerts",
|
||||||
|
"tableTo": "farms",
|
||||||
|
"columnsFrom": [
|
||||||
|
"farm_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"printer_alerts_printer_id_printers_id_fk": {
|
||||||
|
"name": "printer_alerts_printer_id_printers_id_fk",
|
||||||
|
"tableFrom": "printer_alerts",
|
||||||
|
"tableTo": "printers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"printer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.org_role": {
|
||||||
|
"name": "org_role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"viewer",
|
||||||
|
"operator",
|
||||||
|
"owner"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.printer_status": {
|
||||||
|
"name": "printer_status",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"offline",
|
||||||
|
"idle",
|
||||||
|
"printing",
|
||||||
|
"paused",
|
||||||
|
"error",
|
||||||
|
"maintenance"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.printer_vendor": {
|
||||||
|
"name": "printer_vendor",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"bambu",
|
||||||
|
"prusa",
|
||||||
|
"klipper",
|
||||||
|
"octoprint"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.print_job_status": {
|
||||||
|
"name": "print_job_status",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"queued",
|
||||||
|
"uploading",
|
||||||
|
"printing",
|
||||||
|
"paused",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
"cancelled"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"public.alert_kind": {
|
||||||
|
"name": "alert_kind",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"print_failed",
|
||||||
|
"print_paused",
|
||||||
|
"printer_error",
|
||||||
|
"ai_detection"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,34 @@
|
|||||||
"when": 1792399397819,
|
"when": 1792399397819,
|
||||||
"tag": "0003_colorful_electro",
|
"tag": "0003_colorful_electro",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793285676878,
|
||||||
|
"tag": "0004_printer_alerts",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793289367708,
|
||||||
|
"tag": "0005_bouncy_cannonball",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793346305671,
|
||||||
|
"tag": "0006_octoprint_vendor",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793347631289,
|
||||||
|
"tag": "0007_onboarding",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Executable
+216
@@ -0,0 +1,216 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end test of alerts: a stand-in gateway reports a printer going
|
||||||
|
# from printing to failed over the real /ws/edge/v1, and an AI detection is
|
||||||
|
# published on the real Valkey channel; both must become rows an operator can
|
||||||
|
# list and acknowledge.
|
||||||
|
#
|
||||||
|
# This is the test that the unit tests cannot be. tests/authz.test.ts proves
|
||||||
|
# tokens are verified and that the role ladder orders correctly; only this
|
||||||
|
# proves that each route actually asks, that scoping is applied in the query
|
||||||
|
# rather than after it, and that credentials stay out of responses - the three
|
||||||
|
# things that were wrong when every route was unauthenticated.
|
||||||
|
#
|
||||||
|
# Four users, two organisations, no printers or gateways needed:
|
||||||
|
# owner_a, operator_a, viewer_a in org A, which owns farm A and printer A
|
||||||
|
# outsider_b in org B, which owns nothing
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/dev-env.sh up && scripts/dev-env.sh schema
|
||||||
|
# scripts/e2e-authz.sh
|
||||||
|
#
|
||||||
|
# KEEP_DATA=1 leave the seeded organisations behind for inspection
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
PORT="${PORT:-3030}"
|
||||||
|
AUTH_PORT="${AUTH_PORT:-3031}"
|
||||||
|
BASE="http://localhost:$PORT"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||||
|
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||||
|
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||||
|
# Presigning a PUT is local HMAC with no network call, so POST /jobs works
|
||||||
|
# offline - but the SDK refuses to sign with empty credentials.
|
||||||
|
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}"
|
||||||
|
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}"
|
||||||
|
R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}"
|
||||||
|
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
|
||||||
|
export R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
BACKEND_PID=""
|
||||||
|
AUTH_PID=""
|
||||||
|
FAILURES=0
|
||||||
|
|
||||||
|
# `uuidgen` is not installed everywhere; the kernel's generator always is.
|
||||||
|
new_uuid() {
|
||||||
|
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||||
|
cat /proc/sys/kernel/random/uuid
|
||||||
|
else
|
||||||
|
python3 -c 'import uuid; print(uuid.uuid4())'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uses DATABASE_URL rather than a hardcoded localhost. The backend under test
|
||||||
|
# reads that variable, so a psql pointed anywhere else seeds fixtures into a
|
||||||
|
# different database than the one being exercised - and the failure message
|
||||||
|
# below names DATABASE_URL, which would then be naming a URL nothing tried.
|
||||||
|
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
# Never `kill $PID` with an unset PID: `kill 0` signals the whole process
|
||||||
|
# group, which includes this script and whatever launched it.
|
||||||
|
local pids=()
|
||||||
|
[ -n "$BACKEND_PID" ] && pids+=("$BACKEND_PID")
|
||||||
|
[ -n "$AUTH_PID" ] && pids+=("$AUTH_PID")
|
||||||
|
if [ ${#pids[@]} -gt 0 ]; then
|
||||||
|
kill "${pids[@]}" 2>/dev/null || true
|
||||||
|
sleep 0.3
|
||||||
|
kill -9 "${pids[@]}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -z "${KEEP_DATA:-}" ]; then
|
||||||
|
for org in "${ORG_A:-}" "${ORG_B:-}"; do
|
||||||
|
[ -n "$org" ] && psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo
|
||||||
|
echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
ok() { echo " ok: $*"; }
|
||||||
|
bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); }
|
||||||
|
|
||||||
|
# `req <method> <path> <token|-> [json body]` -> prints the status code,
|
||||||
|
# leaves the response body in $WORK/last.json.
|
||||||
|
req() {
|
||||||
|
local method="$1" path="$2" token="$3" body="${4:-}"
|
||||||
|
local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$method" "$BASE$path")
|
||||||
|
[ "$token" != "-" ] && args+=(-H "authorization: Bearer $token")
|
||||||
|
[ -n "$body" ] && args+=(-H 'content-type: application/json' -d "$body")
|
||||||
|
curl "${args[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# `expect <code> <method> <path> <token> [body] -- <description>`
|
||||||
|
expect() {
|
||||||
|
local want="$1"; shift
|
||||||
|
local method="$1" path="$2" token="$3"; shift 3
|
||||||
|
local body=""
|
||||||
|
if [ "${1:-}" != "--" ]; then body="$1"; shift; fi
|
||||||
|
shift # the --
|
||||||
|
local what="$*"
|
||||||
|
local got; got="$(req "$method" "$path" "$token" "$body")"
|
||||||
|
if [ "$got" = "$want" ]; then
|
||||||
|
ok "$what ($got)"
|
||||||
|
else
|
||||||
|
bad "$what: expected $want, got $got - $(head -c 300 "$WORK/last.json")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
body_has() { grep -q "$1" "$WORK/last.json" || bad "expected '$1' in: $(head -c 300 "$WORK/last.json")"; }
|
||||||
|
body_lacks() { grep -q "$1" "$WORK/last.json" && bad "'$1' leaked into: $(head -c 300 "$WORK/last.json")" || true; }
|
||||||
|
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; }
|
||||||
|
|
||||||
|
# --- 0. preconditions ------------------------------------------------------
|
||||||
|
|
||||||
|
echo "==> checking the database is up and migrated"
|
||||||
|
psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up"
|
||||||
|
psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \
|
||||||
|
|| fail "farms.org_id is missing - run scripts/dev-env.sh schema"
|
||||||
|
psql_q "SELECT role FROM org_members LIMIT 0" >/dev/null 2>&1 \
|
||||||
|
|| fail "org_members is missing - run scripts/dev-env.sh schema"
|
||||||
|
ok "schema has the org layer"
|
||||||
|
|
||||||
|
# --- 1. the issuer and the backend ----------------------------------------
|
||||||
|
|
||||||
|
echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT"
|
||||||
|
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
|
||||||
|
AUTH_PID=$!
|
||||||
|
AUTH_URL="http://localhost:$AUTH_PORT"
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \
|
||||||
|
|| fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")"
|
||||||
|
export SUPABASE_URL="$AUTH_URL"
|
||||||
|
ok "issuer up, SUPABASE_URL=$SUPABASE_URL"
|
||||||
|
|
||||||
|
mint() { curl -sf "$AUTH_URL/token?sub=$1" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; }
|
||||||
|
|
||||||
|
|
||||||
|
echo "==> starting continuum-backend on :$PORT"
|
||||||
|
DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
curl -sf "$BASE/health" >/dev/null 2>&1 && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
curl -sf "$BASE/health" >/dev/null 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")"
|
||||||
|
ok "backend healthy"
|
||||||
|
|
||||||
|
# --- 2. a farm with one claimed printer ---------------------------------
|
||||||
|
|
||||||
|
OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")"
|
||||||
|
expect 201 POST /orgs "$OWNER" '{"name":"e2e alerts org"}' -- "create an org"
|
||||||
|
ORG_A="$(json 'd["id"]')"
|
||||||
|
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e alerts farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm"
|
||||||
|
FARM_A="$(json 'd["id"]')"
|
||||||
|
expect 201 POST /printers "$OWNER" \
|
||||||
|
"{\"farmId\":\"$FARM_A\",\"name\":\"Fake P1\",\"vendor\":\"bambu\",\"gatewayPrinterId\":\"fake1\"}" -- "claim a printer"
|
||||||
|
PRINTER_A="$(json 'd["id"]')"
|
||||||
|
expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER" -- "mint the gateway token"
|
||||||
|
PROXY_TOKEN="$(json 'd["edgeProxyToken"]')"
|
||||||
|
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$PROXY_TOKEN\"}" -- "trade it for an edge JWT"
|
||||||
|
EDGE_JWT="$(json 'd["token"]')"
|
||||||
|
[ -n "$EDGE_JWT" ] || fail "no edge token in the response"
|
||||||
|
|
||||||
|
# --- 3. telemetry -> alert ------------------------------------------------
|
||||||
|
|
||||||
|
echo "==> a print that fails raises exactly one alert"
|
||||||
|
# The first frame is only an observation (nothing is known before it), so
|
||||||
|
# IDLE,RUNNING,RUNNING,FAILED,FAILED has one transition worth raising.
|
||||||
|
bun scripts/fake-gateway.ts "ws://localhost:$PORT/ws/edge/v1" "$EDGE_JWT" fake1 IDLE,RUNNING,RUNNING,FAILED,FAILED \
|
||||||
|
|| fail "the fake gateway could not talk to the backend"
|
||||||
|
N="$(psql_q "SELECT count(*) FROM printer_alerts WHERE farm_id='$FARM_A' AND kind='print_failed'")"
|
||||||
|
[ "$N" = "1" ] && ok "one print_failed alert from a printing -> failed transition" || bad "expected 1 print_failed alert, found $N"
|
||||||
|
TOTAL="$(psql_q "SELECT count(*) FROM printer_alerts WHERE farm_id='$FARM_A'")"
|
||||||
|
[ "$TOTAL" = "1" ] && ok "and nothing else: staying failed and idle -> running are quiet" || bad "expected 1 alert in total, found $TOTAL"
|
||||||
|
|
||||||
|
echo "==> a reconnect does not re-announce what was already known"
|
||||||
|
bun scripts/fake-gateway.ts "ws://localhost:$PORT/ws/edge/v1" "$EDGE_JWT" fake1 FAILED,FAILED || fail "reconnect failed"
|
||||||
|
TOTAL="$(psql_q "SELECT count(*) FROM printer_alerts WHERE farm_id='$FARM_A'")"
|
||||||
|
[ "$TOTAL" = "1" ] && ok "still one alert after reconnecting into the same state" || bad "reconnect raised alerts: $TOTAL"
|
||||||
|
|
||||||
|
expect 200 GET "/alerts?open=true&farmId=$FARM_A" "$OWNER" -- "the owner lists open alerts"
|
||||||
|
body_has "the print failed"
|
||||||
|
body_has "Fake P1"
|
||||||
|
|
||||||
|
# --- 4. AI detection -> alert --------------------------------------------
|
||||||
|
|
||||||
|
echo "==> an AI detection on the Valkey channel becomes a row (above the floor only)"
|
||||||
|
publish() { valkey-cli -u "$VALKEY_URL" PUBLISH channel:print_alerts "$1" >/dev/null; }
|
||||||
|
mk() { printf '{"printer_id":"%s","label":"spaghetti","confidence":%s,"captured_at":"2026-10-29T10:00:00Z","r2_object_key":"frames/e2e.jpg"}' "$PRINTER_A" "$1"; }
|
||||||
|
publish "$(mk 0.95)"
|
||||||
|
publish "$(mk 0.10)"
|
||||||
|
publish 'this is not json'
|
||||||
|
sleep 1
|
||||||
|
AI="$(psql_q "SELECT count(*) FROM printer_alerts WHERE farm_id='$FARM_A' AND kind='ai_detection'")"
|
||||||
|
[ "$AI" = "1" ] && ok "the 0.95 detection was recorded; the 0.10 one and the junk were not" || bad "expected 1 ai_detection alert, found $AI"
|
||||||
|
CONF="$(psql_q "SELECT confidence FROM printer_alerts WHERE farm_id='$FARM_A' AND kind='ai_detection'")"
|
||||||
|
[ "$CONF" = "0.95" ] && ok "confidence stored as published" || bad "confidence is '$CONF'"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAILURES" -eq 0 ]; then
|
||||||
|
echo "PASS - telemetry transitions and AI detections both become alerts."
|
||||||
|
else
|
||||||
|
echo "FAIL - $FAILURES assertion(s) failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -305,6 +305,28 @@ CODE="$(req POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":
|
|||||||
expect 401 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"not-the-token\"}" \
|
expect 401 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"not-the-token\"}" \
|
||||||
-- "a wrong proxy token is refused"
|
-- "a wrong proxy token is refused"
|
||||||
|
|
||||||
|
# --- 7b. alerts ---------------------------------------------------------
|
||||||
|
|
||||||
|
echo "==> alerts are scoped, and acknowledging is an operator's decision"
|
||||||
|
ALERT_A="$(new_uuid)"
|
||||||
|
psql_q "INSERT INTO printer_alerts (id, farm_id, printer_id, kind, message) VALUES ('$ALERT_A', '$FARM_A', '$PRINTER_A', 'print_failed', 'e2e: the print failed')" >/dev/null
|
||||||
|
expect 200 GET "/alerts?open=true" "$VIEWER_A" -- "a viewer lists open alerts"
|
||||||
|
body_has "$ALERT_A"
|
||||||
|
expect 200 GET "/alerts?open=true" "$OUTSIDER" -- "the outsider lists open alerts"
|
||||||
|
body_lacks "$ALERT_A"
|
||||||
|
ok "the outsider's alert list does not contain farm A's alert"
|
||||||
|
expect 404 POST "/alerts/$ALERT_A/acknowledge" "$OUTSIDER" -- "the outsider cannot acknowledge another org's alert"
|
||||||
|
expect 403 POST "/alerts/$ALERT_A/acknowledge" "$VIEWER_A" -- "a viewer cannot acknowledge"
|
||||||
|
expect 200 POST "/alerts/$ALERT_A/acknowledge" "$OPERATOR_A" -- "an operator acknowledges"
|
||||||
|
WHO="$(json 'd["acknowledgedBy"]')"
|
||||||
|
[ "$WHO" = "$OPERATOR_A_ID" ] && ok "acknowledgedBy came from the token" || bad "acknowledgedBy is '$WHO'"
|
||||||
|
expect 200 POST "/alerts/$ALERT_A/acknowledge" "$OWNER_A" -- "acknowledging again is harmless"
|
||||||
|
WHO2="$(json 'd["acknowledgedBy"]')"
|
||||||
|
[ "$WHO2" = "$OPERATOR_A_ID" ] && ok "the first acknowledger stays on the record" || bad "second ack overwrote the record: '$WHO2'"
|
||||||
|
expect 200 GET "/alerts?open=true" "$VIEWER_A" -- "an acknowledged alert leaves the open list"
|
||||||
|
body_lacks "$ALERT_A"
|
||||||
|
expect 401 GET /alerts - -- "GET /alerts rejects anonymous"
|
||||||
|
|
||||||
# --- 8. the guards that stop an org becoming unadministrable --------------
|
# --- 8. the guards that stop an org becoming unadministrable --------------
|
||||||
|
|
||||||
echo "==> an organisation cannot be left without an owner"
|
echo "==> an organisation cannot be left without an owner"
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* The P1/A1 camera in the real web console, in a real Chrome, for
|
||||||
|
* scripts/e2e-camera-snapshot.sh (BROWSER=1). Signs in the same way as
|
||||||
|
* e2e-camera-browser.ts. Reads pixel statistics and keeps no screenshot: the
|
||||||
|
* frame is the inside of someone's workshop.
|
||||||
|
*
|
||||||
|
* Results are `ok<TAB>label` / `FAILED<TAB>label`.
|
||||||
|
* Environment: APP_URL, SUPABASE_E2E_EMAIL, SUPABASE_E2E_PASSWORD, CHROME_BIN.
|
||||||
|
*/
|
||||||
|
import puppeteer from "puppeteer-core";
|
||||||
|
|
||||||
|
const appUrl = process.env.APP_URL!;
|
||||||
|
const email = process.env.SUPABASE_E2E_EMAIL!;
|
||||||
|
const password = process.env.SUPABASE_E2E_PASSWORD!;
|
||||||
|
const chromeBin = process.env.CHROME_BIN ?? "/usr/bin/google-chrome";
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
const say = (good: boolean, label: string, detail = "") => {
|
||||||
|
if (!good) failed = true;
|
||||||
|
console.log(`${good ? "ok" : "FAILED"}\t${label}${detail ? ` (${detail})` : ""}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath: chromeBin,
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
page.on("pageerror", (err) => pageErrors.push(String((err as Error).message ?? err).slice(0, 160)));
|
||||||
|
|
||||||
|
await page.goto(`${appUrl}/login`, { waitUntil: "domcontentloaded", timeout: 120_000 });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => typeof (window as any).useNuxtApp === "function" && Boolean((window as any).useNuxtApp().$supabase),
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
);
|
||||||
|
const signInError = await page.evaluate(
|
||||||
|
async (e: string, p: string) => {
|
||||||
|
const { client } = (window as any).useNuxtApp().$supabase;
|
||||||
|
const { error } = await client.auth.signInWithPassword({ email: e, password: p });
|
||||||
|
return error ? String(error.message) : null;
|
||||||
|
},
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
say(signInError === null, "the app's own Supabase client signs the user in", signInError ?? "");
|
||||||
|
if (signInError) process.exit(1);
|
||||||
|
|
||||||
|
await page.goto(`${appUrl}/`, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||||
|
const stayed = await page.waitForSelector(".fleet", { timeout: 60_000 }).then(() => true).catch(() => false);
|
||||||
|
say(stayed && !page.url().includes("/login"), "the fleet page loads and does not bounce back to /login");
|
||||||
|
|
||||||
|
const live = await page
|
||||||
|
.waitForFunction(() => document.querySelector(".app-shell__status.is-online") !== null, { timeout: 30_000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
say(live, "the header reports Live");
|
||||||
|
|
||||||
|
const shown = await page
|
||||||
|
.waitForFunction(() => document.querySelector(".snapshot-view__status.is-connected") !== null, { timeout: 60_000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
const status = await page.evaluate(
|
||||||
|
() => document.querySelector(".snapshot-view__status")?.textContent?.trim() ?? "no snapshot view rendered",
|
||||||
|
);
|
||||||
|
say(shown, "the snapshot view reports connected", `status: ${status}`);
|
||||||
|
say(
|
||||||
|
(await page.$(".webrtc-view")) === null,
|
||||||
|
"no WebRTC player is rendered for a snapshot camera",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shown) {
|
||||||
|
// Two different frames, a few seconds apart: the picture is refreshing.
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const img = document.querySelector<HTMLImageElement>(".snapshot-view img")!;
|
||||||
|
const first = img.src;
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (img.src === first && Date.now() - t0 < 15_000) await new Promise((r) => setTimeout(r, 250));
|
||||||
|
const refreshed = img.src !== first;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 64;
|
||||||
|
canvas.height = 36;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
const px = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
let sum = 0;
|
||||||
|
let sumSq = 0;
|
||||||
|
const n = px.length / 4;
|
||||||
|
for (let i = 0; i < px.length; i += 4) {
|
||||||
|
const y = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
|
||||||
|
sum += y;
|
||||||
|
sumSq += y * y;
|
||||||
|
}
|
||||||
|
const mean = sum / n;
|
||||||
|
return {
|
||||||
|
refreshed,
|
||||||
|
width: img.naturalWidth,
|
||||||
|
height: img.naturalHeight,
|
||||||
|
mean,
|
||||||
|
stdev: Math.sqrt(Math.max(0, sumSq / n - mean * mean)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
say(result.width > 0, "Chrome decoded the JPEG", `${result.width}x${result.height}`);
|
||||||
|
say(result.refreshed, "the picture refreshes by itself");
|
||||||
|
say(result.stdev > 2, "the frame has picture content, not black or one flat colour",
|
||||||
|
`mean luma ${result.mean.toFixed(0)}, stdev ${result.stdev.toFixed(1)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
say(pageErrors.length === 0, "the page raised no uncaught errors", pageErrors.slice(0, 2).join(" | "));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
Executable
+215
@@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# A P1/A1 camera, end to end: the real proxy reads a JPEG frame from the real
|
||||||
|
# printer's port 6000 (read-only - it only watches), the real backend relays
|
||||||
|
# it, and an authenticated viewer fetches it.
|
||||||
|
#
|
||||||
|
# Needs a printer in ../continuum-proxy/printers.toml whose camera is the
|
||||||
|
# P1/A1 kind (CAMERA_ID, default p1p) and that is switched on. Touches the
|
||||||
|
# printer's camera and MQTT status, nothing else; no command is sent.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
CAMERA_ID="${CAMERA_ID:-p1p}"
|
||||||
|
GO2RTC_BIN="${GO2RTC_BIN:-$HOME/.local/bin/go2rtc}"
|
||||||
|
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
|
||||||
|
PORT="${PORT:-3050}"
|
||||||
|
AUTH_PORT="${AUTH_PORT:-3051}"
|
||||||
|
BASE="http://localhost:$PORT"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||||
|
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||||
|
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||||
|
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}"
|
||||||
|
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}"
|
||||||
|
R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}"
|
||||||
|
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
|
||||||
|
# Where to keep the last frame for a human to look at. Unset: not kept.
|
||||||
|
SAVE_FRAME="${SAVE_FRAME:-}"
|
||||||
|
# BROWSER=1: also open the real web console in real Chrome (needs the real
|
||||||
|
# Supabase test user, so source ../dev-secrets.sh).
|
||||||
|
BROWSER="${BROWSER:-0}"
|
||||||
|
APP_HOST="${APP_HOST:-127.0.0.1}"
|
||||||
|
APP_DIR="${APP_DIR:-$REPO_ROOT/../continuum-app}"
|
||||||
|
APP_PORT="${APP_PORT:-3052}"
|
||||||
|
CHROME_BIN="${CHROME_BIN:-/usr/bin/google-chrome}"
|
||||||
|
APP_PID=""
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
BACKEND_PID=""; PROXY_PID=""; AUTH_PID=""
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
ok() { echo " ok: $*"; }
|
||||||
|
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
|
||||||
|
USER_ID="$(cat /proc/sys/kernel/random/uuid)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done
|
||||||
|
[ -n "$APP_PID" ] && kill -- "-$APP_PID" 2>/dev/null || true
|
||||||
|
for _ in $(seq 1 20); do [ -n "$PROXY_PID" ] && kill -0 "$PROXY_PID" 2>/dev/null || break; sleep 0.3; done
|
||||||
|
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done
|
||||||
|
[ -n "$APP_PID" ] && kill -9 -- "-$APP_PID" 2>/dev/null || true
|
||||||
|
[ -n "${ORG_ID:-}" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo; echo "--- backend log (tail) ---"; tail -20 "$WORK/backend.log" 2>/dev/null || true
|
||||||
|
echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
sleep 0.5
|
||||||
|
if ps -eo comm | grep -qx go2rtc; then
|
||||||
|
echo "WARNING: a go2rtc process is still running after cleanup" >&2; [ $rc -eq 0 ] && rc=1
|
||||||
|
fi
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "==> checking preconditions"
|
||||||
|
psql_q "SELECT camera_snapshot FROM printers LIMIT 0" >/dev/null 2>&1 \
|
||||||
|
|| fail "database lacks printers.camera_snapshot - run scripts/dev-env.sh schema"
|
||||||
|
[ -x "$GO2RTC_BIN" ] || fail "no go2rtc at $GO2RTC_BIN"
|
||||||
|
[ -f "$PROXY_DIR/printers.toml" ] || fail "no $PROXY_DIR/printers.toml"
|
||||||
|
grep -q "^id *= *\"$CAMERA_ID\"" "$PROXY_DIR/printers.toml" || fail "printers.toml has no printer '$CAMERA_ID'"
|
||||||
|
ps -eo comm | grep -qx go2rtc && fail "a go2rtc is already running"
|
||||||
|
CAM_HOST="$(awk -v id="$CAMERA_ID" -F'"' '/^id *=/ {cur=$2} cur==id && /^(host|address|ip) *=/ {print $2; exit}' "$PROXY_DIR/printers.toml")"
|
||||||
|
if [ -n "$CAM_HOST" ]; then
|
||||||
|
timeout 4 bash -c "</dev/tcp/$CAM_HOST/6000" 2>/dev/null || fail "$CAMERA_ID ($CAM_HOST) refuses TCP 6000: no snapshot camera, or it is off"
|
||||||
|
ok "$CAMERA_ID accepts connections on 6000"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BROWSER" = "1" ]; then
|
||||||
|
for name in SUPABASE_URL SUPABASE_SECRET_KEY SUPABASE_PUBLISHABLE_KEY; do
|
||||||
|
[ -n "${!name:-}" ] || fail "$name is not set - source ../dev-secrets.sh (BROWSER=1)"
|
||||||
|
done
|
||||||
|
[ -x "$CHROME_BIN" ] || fail "no Chrome at $CHROME_BIN"
|
||||||
|
[ -d "$APP_DIR/apps/web/node_modules" ] || fail "$APP_DIR has no installed web app"
|
||||||
|
SB_USER="$(bun scripts/e2e-supabase-user.ts)" || fail "could not provision the Supabase test user"
|
||||||
|
sb_field() { printf '%s' "$SB_USER" | python3 -c 'import json,sys; print(json.load(sys.stdin)[sys.argv[1]])' "$1"; }
|
||||||
|
USER_ID="$(sb_field id)"; USER_TOKEN="$(sb_field accessToken)"; SB_EMAIL="$(sb_field email)"; SB_PASSWORD="$(sb_field password)"
|
||||||
|
unset SB_USER
|
||||||
|
ok "signed in as the real Supabase test user"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> seeding an organisation and a farm claiming '$CAMERA_ID'"
|
||||||
|
EDGE_PROXY_TOKEN="e2e-$(date +%s)"
|
||||||
|
ORG_ID="$(psql_q "INSERT INTO organisations (name) VALUES ('e2e snapshot org') RETURNING id")"
|
||||||
|
psql_q "INSERT INTO org_members (org_id, user_id, role) VALUES ('$ORG_ID', '$USER_ID', 'viewer')" >/dev/null
|
||||||
|
FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token) VALUES ('e2e snapshot farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")"
|
||||||
|
psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id) VALUES ('$FARM_ID', 'snapshot under test', 'bambu', '$CAMERA_ID')" >/dev/null
|
||||||
|
CAM_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID'")"
|
||||||
|
|
||||||
|
echo "==> starting the auth issuer and the backend"
|
||||||
|
if [ "$BROWSER" != "1" ]; then
|
||||||
|
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
|
||||||
|
AUTH_PID=$!
|
||||||
|
AUTH_URL="http://localhost:$AUTH_PORT"
|
||||||
|
for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done
|
||||||
|
export SUPABASE_URL="$AUTH_URL"
|
||||||
|
USER_TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')"
|
||||||
|
fi
|
||||||
|
DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && break; sleep 0.5; done
|
||||||
|
curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy"
|
||||||
|
|
||||||
|
echo "==> starting the real continuum-proxy"
|
||||||
|
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
|
||||||
|
env -C "$PROXY_DIR" \
|
||||||
|
CONTINUUM_GATEWAY_ID=e2e-snap-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \
|
||||||
|
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
|
||||||
|
CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
||||||
|
CONTINUUM_PRINTERS_PATH=printers.toml CONTINUUM_STORE_PATH="$WORK/continuum.db" RUST_LOG=info \
|
||||||
|
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
|
||||||
|
PROXY_PID=$!
|
||||||
|
|
||||||
|
echo "==> waiting for the camera to be advertised as a snapshot camera"
|
||||||
|
STREAM=""
|
||||||
|
for _ in $(seq 1 45); do
|
||||||
|
STREAM="$(curl -sf -H "authorization: Bearer $USER_TOKEN" "$BASE/printers/$CAM_UUID/stream" 2>/dev/null || true)"
|
||||||
|
[ -n "$STREAM" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ -n "$STREAM" ] || fail "GET /printers/$CAM_UUID/stream never answered 200"
|
||||||
|
printf '%s' "$STREAM" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["kind"]=="snapshot" and "iceServers" not in d, d' \
|
||||||
|
|| fail "the stream answer is not a snapshot answer"
|
||||||
|
ok "the stream endpoint says kind=snapshot (no WebRTC session offered)"
|
||||||
|
|
||||||
|
echo "==> fetching frames"
|
||||||
|
check_frame() {
|
||||||
|
local file="$1" label="$2" size
|
||||||
|
size="$(stat -c %s "$file")"
|
||||||
|
[ "$size" -gt 1000 ] || fail "$label: only $size bytes"
|
||||||
|
[ "$(head -c2 "$file" | od -An -tx1 | tr -d ' \n')" = "ffd8" ] || fail "$label: does not start with a JPEG marker"
|
||||||
|
[ "$(tail -c2 "$file" | od -An -tx1 | tr -d ' \n')" = "ffd9" ] || fail "$label: does not end with a JPEG marker"
|
||||||
|
ok "$label: a $size-byte JPEG"
|
||||||
|
}
|
||||||
|
fetch() { curl -s -o "$1" -D "$1.h" -w '%{http_code}' -H "authorization: Bearer $USER_TOKEN" "$BASE/printers/$CAM_UUID/snapshot"; }
|
||||||
|
code="$(fetch "$WORK/f1.jpg")"
|
||||||
|
[ "$code" = 200 ] || fail "first frame: HTTP $code: $(head -c 300 "$WORK/f1.jpg")"
|
||||||
|
grep -qi '^content-type: image/jpeg' "$WORK/f1.jpg.h" || fail "wrong content-type"
|
||||||
|
grep -qi '^cache-control: no-store' "$WORK/f1.jpg.h" || fail "frames must be no-store"
|
||||||
|
check_frame "$WORK/f1.jpg" "first frame"
|
||||||
|
sleep 1.2
|
||||||
|
code="$(fetch "$WORK/f2.jpg")"; [ "$code" = 200 ] || fail "second frame: HTTP $code"
|
||||||
|
check_frame "$WORK/f2.jpg" "second frame (a fresh read after the cache window)"
|
||||||
|
cmp -s "$WORK/f1.jpg" "$WORK/f2.jpg" && fail "two frames a second apart are byte-identical - is the cache serving stale pictures?"
|
||||||
|
ok "consecutive frames differ"
|
||||||
|
|
||||||
|
echo "==> five viewers at once share the camera"
|
||||||
|
viewers=()
|
||||||
|
for i in 1 2 3 4 5; do fetch "$WORK/c$i.jpg" > "$WORK/c$i.code" & viewers+=($!); done
|
||||||
|
# Not a bare `wait`: that would also wait for the backend and the proxy.
|
||||||
|
for pid in "${viewers[@]}"; do wait "$pid"; done
|
||||||
|
for i in 1 2 3 4 5; do [ "$(cat "$WORK/c$i.code")" = 200 ] || fail "viewer $i: HTTP $(cat "$WORK/c$i.code")"; check_frame "$WORK/c$i.jpg" "viewer $i"; done
|
||||||
|
ok "all five got a picture"
|
||||||
|
|
||||||
|
echo "==> access control"
|
||||||
|
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/printers/$CAM_UUID/snapshot")" = 401 ] || fail "no token must be 401"
|
||||||
|
ok "no token: 401"
|
||||||
|
|
||||||
|
# The code is never read out of printers.toml here; the camera login happens
|
||||||
|
# on the gateway, and the only thing that should cross is the picture.
|
||||||
|
if grep -qi 'access_code\|accessCode' "$WORK/backend.log"; then
|
||||||
|
fail "the backend log mentions an access code"
|
||||||
|
fi
|
||||||
|
ok "the backend log never mentions an access code"
|
||||||
|
|
||||||
|
# Optional soak: the first real dispatch froze the proxy ~4.5 min in (an
|
||||||
|
# MQTT publish that never completed while the fleet lock was held). Staying
|
||||||
|
# connected well past that and still receiving telemetry is the regression test.
|
||||||
|
if [ -n "${SOAK_S:-}" ]; then
|
||||||
|
echo "==> soaking for ${SOAK_S}s: telemetry must keep arriving"
|
||||||
|
sleep "$SOAK_S"
|
||||||
|
AGE="$(psql_q "SELECT extract(epoch FROM now() - last_seen_at)::int FROM printers WHERE id='$CAM_UUID'")"
|
||||||
|
[ -n "$AGE" ] && [ "$AGE" -lt 20 ] || fail "telemetry is ${AGE:-never}s old after the soak - the proxy froze"
|
||||||
|
ok "telemetry still fresh (${AGE}s old) after ${SOAK_S}s"
|
||||||
|
code="$(fetch "$WORK/after.jpg")"; [ "$code" = 200 ] || fail "no camera frame after the soak: HTTP $code"
|
||||||
|
ok "camera still answers after the soak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$BROWSER" = "1" ]; then
|
||||||
|
echo "==> the real console in real Chrome"
|
||||||
|
setsid env -C "$APP_DIR/apps/web" NUXT_PUBLIC_API_BASE="$BASE" NUXT_PUBLIC_WS_BASE="ws://localhost:$PORT" \
|
||||||
|
SUPABASE_URL="$SUPABASE_URL" SUPABASE_KEY="$SUPABASE_PUBLISHABLE_KEY" NUXT_PUBLIC_PASSWORD_LOGIN=true \
|
||||||
|
pnpm exec nuxt dev --host "$APP_HOST" --port "$APP_PORT" > "$WORK/app.log" 2>&1 &
|
||||||
|
APP_PID=$!
|
||||||
|
for _ in $(seq 1 120); do curl -sf -o /dev/null "http://127.0.0.1:$APP_PORT/login" 2>/dev/null && break; sleep 1; done
|
||||||
|
curl -sf -o /dev/null "http://127.0.0.1:$APP_PORT/login" || { tail -20 "$WORK/app.log" >&2; fail "the web console did not start"; }
|
||||||
|
OUT="$(env APP_URL="http://127.0.0.1:$APP_PORT" SUPABASE_E2E_EMAIL="$SB_EMAIL" SUPABASE_E2E_PASSWORD="$SB_PASSWORD" \
|
||||||
|
CHROME_BIN="$CHROME_BIN" timeout 240 bun scripts/e2e-camera-snapshot-browser.ts 2>&1 || true)"
|
||||||
|
seen=0; bad=0
|
||||||
|
while IFS=$'\t' read -r verdict label; do
|
||||||
|
case "$verdict" in
|
||||||
|
ok) ok "$label"; seen=$((seen + 1)) ;;
|
||||||
|
FAILED) echo " FAILED: $label" >&2; seen=$((seen + 1)); bad=$((bad + 1)) ;;
|
||||||
|
esac
|
||||||
|
done < <(printf '%s\n' "$OUT" | grep -E "^(ok|FAILED)$(printf '\t')")
|
||||||
|
if [ "$seen" -lt 8 ] && [ "$bad" -eq 0 ]; then bad=1; echo " FAILED: only $seen of 8 browser results" >&2; fi
|
||||||
|
if [ "$bad" -ne 0 ]; then
|
||||||
|
printf '%s\n' "$OUT" | sed 's/^/ | /' >&2; tail -15 "$WORK/app.log" >&2
|
||||||
|
fail "the real browser did not show the snapshot camera"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -n "$SAVE_FRAME" ] && cp "$WORK/f2.jpg" "$SAVE_FRAME" && ok "kept a frame at $SAVE_FRAME"
|
||||||
|
echo
|
||||||
|
echo "PASS - a P1/A1 camera frame travelled printer -> proxy -> backend -> an authenticated viewer."
|
||||||
@@ -268,12 +268,13 @@ echo "==> starting the real continuum-proxy, which spawns the real go2rtc"
|
|||||||
env -C "$PROXY_DIR" \
|
env -C "$PROXY_DIR" \
|
||||||
CONTINUUM_GATEWAY_ID=e2e-cam-gw \
|
CONTINUUM_GATEWAY_ID=e2e-cam-gw \
|
||||||
CONTINUUM_FARM_ID="$FARM_ID" \
|
CONTINUUM_FARM_ID="$FARM_ID" \
|
||||||
CONTINUUM_GATEWAY_TOKEN="$TOKEN" \
|
CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \
|
||||||
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
|
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
|
||||||
CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" \
|
CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" \
|
||||||
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
||||||
CONTINUUM_STREAM_BASE_URL="http://e2e-gateway.invalid:1984" \
|
CONTINUUM_STREAM_BASE_URL="http://e2e-gateway.invalid:1984" \
|
||||||
CONTINUUM_PRINTERS_PATH=printers.toml \
|
CONTINUUM_PRINTERS_PATH=printers.toml \
|
||||||
|
CONTINUUM_STORE_PATH="$WORK/continuum.db" \
|
||||||
RUST_LOG=info \
|
RUST_LOG=info \
|
||||||
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
|
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
|
||||||
PROXY_PID=$!
|
PROXY_PID=$!
|
||||||
|
|||||||
+2
-1
@@ -209,7 +209,7 @@ STREAM_BASE_URL="http://e2e-gateway.invalid:1984"
|
|||||||
env -C "$PROXY_DIR" \
|
env -C "$PROXY_DIR" \
|
||||||
CONTINUUM_GATEWAY_ID=e2e-gw \
|
CONTINUUM_GATEWAY_ID=e2e-gw \
|
||||||
CONTINUUM_FARM_ID="$FARM_ID" \
|
CONTINUUM_FARM_ID="$FARM_ID" \
|
||||||
CONTINUUM_GATEWAY_TOKEN="$TOKEN" \
|
CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \
|
||||||
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
|
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
|
||||||
`# Deliberately absent, not an oversight: what is under test is the config` \
|
`# Deliberately absent, not an oversight: what is under test is the config` \
|
||||||
`# the gateway writes and the camera_stream it advertises, both of which` \
|
`# the gateway writes and the camera_stream it advertises, both of which` \
|
||||||
@@ -220,6 +220,7 @@ env -C "$PROXY_DIR" \
|
|||||||
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
||||||
CONTINUUM_STREAM_BASE_URL="$STREAM_BASE_URL" \
|
CONTINUUM_STREAM_BASE_URL="$STREAM_BASE_URL" \
|
||||||
CONTINUUM_PRINTERS_PATH=printers.toml \
|
CONTINUUM_PRINTERS_PATH=printers.toml \
|
||||||
|
CONTINUUM_STORE_PATH="$WORK/continuum.db" \
|
||||||
RUST_LOG=info \
|
RUST_LOG=info \
|
||||||
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
|
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
|
||||||
PROXY_PID=$!
|
PROXY_PID=$!
|
||||||
|
|||||||
Executable
+242
@@ -0,0 +1,242 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end test of the print loop, with every machine faked: upload a file to
|
||||||
|
# the REAL object store through the presigned URL the API hands out, let the
|
||||||
|
# REAL scheduler dispatch it over the REAL /ws/edge/v1 to a stand-in gateway,
|
||||||
|
# which downloads it from the REAL presigned GET and hashes it, then reports
|
||||||
|
# the print running and finished. The job must be completed, and the bytes the
|
||||||
|
# gateway received must be the bytes uploaded.
|
||||||
|
#
|
||||||
|
# SAFETY: dispatch is ON here, deliberately - so this is only safe because the
|
||||||
|
# only gateway this backend instance can have is the stand-in (a real proxy
|
||||||
|
# would have to be pointed at this instance's random port). No printer is
|
||||||
|
# touched.
|
||||||
|
#
|
||||||
|
# Needs the real object-store settings: set -a; . ../dev-secrets.sh; set +a
|
||||||
|
#
|
||||||
|
# This is the test that the unit tests cannot be. tests/authz.test.ts proves
|
||||||
|
# tokens are verified and that the role ladder orders correctly; only this
|
||||||
|
# proves that each route actually asks, that scoping is applied in the query
|
||||||
|
# rather than after it, and that credentials stay out of responses - the three
|
||||||
|
# things that were wrong when every route was unauthenticated.
|
||||||
|
#
|
||||||
|
# Four users, two organisations, no printers or gateways needed:
|
||||||
|
# owner_a, operator_a, viewer_a in org A, which owns farm A and printer A
|
||||||
|
# outsider_b in org B, which owns nothing
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/dev-env.sh up && scripts/dev-env.sh schema
|
||||||
|
# scripts/e2e-authz.sh
|
||||||
|
#
|
||||||
|
# KEEP_DATA=1 leave the seeded organisations behind for inspection
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
PORT="${PORT:-3037}"
|
||||||
|
AUTH_PORT="${AUTH_PORT:-3038}"
|
||||||
|
BASE="http://localhost:$PORT"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||||
|
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||||
|
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||||
|
# Presigning a PUT is local HMAC with no network call, so POST /jobs works
|
||||||
|
# offline - but the SDK refuses to sign with empty credentials.
|
||||||
|
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-}"
|
||||||
|
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-}"
|
||||||
|
R2_ENDPOINT="${R2_ENDPOINT:-}"
|
||||||
|
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
|
||||||
|
export R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
BACKEND_PID=""
|
||||||
|
AUTH_PID=""
|
||||||
|
FAILURES=0
|
||||||
|
|
||||||
|
# `uuidgen` is not installed everywhere; the kernel's generator always is.
|
||||||
|
new_uuid() {
|
||||||
|
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||||
|
cat /proc/sys/kernel/random/uuid
|
||||||
|
else
|
||||||
|
python3 -c 'import uuid; print(uuid.uuid4())'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uses DATABASE_URL rather than a hardcoded localhost. The backend under test
|
||||||
|
# reads that variable, so a psql pointed anywhere else seeds fixtures into a
|
||||||
|
# different database than the one being exercised - and the failure message
|
||||||
|
# below names DATABASE_URL, which would then be naming a URL nothing tried.
|
||||||
|
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
# Never `kill $PID` with an unset PID: `kill 0` signals the whole process
|
||||||
|
# group, which includes this script and whatever launched it.
|
||||||
|
local pids=()
|
||||||
|
[ -n "$BACKEND_PID" ] && pids+=("$BACKEND_PID")
|
||||||
|
[ -n "$AUTH_PID" ] && pids+=("$AUTH_PID")
|
||||||
|
if [ ${#pids[@]} -gt 0 ]; then
|
||||||
|
kill "${pids[@]}" 2>/dev/null || true
|
||||||
|
sleep 0.3
|
||||||
|
kill -9 "${pids[@]}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -z "${KEEP_DATA:-}" ]; then
|
||||||
|
for org in "${ORG_A:-}" "${ORG_B:-}"; do
|
||||||
|
[ -n "$org" ] && psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo
|
||||||
|
echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true
|
||||||
|
echo "--- fake gateway log ---"; cat "$WORK/gw.log" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
ok() { echo " ok: $*"; }
|
||||||
|
bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); }
|
||||||
|
|
||||||
|
# `req <method> <path> <token|-> [json body]` -> prints the status code,
|
||||||
|
# leaves the response body in $WORK/last.json.
|
||||||
|
req() {
|
||||||
|
local method="$1" path="$2" token="$3" body="${4:-}"
|
||||||
|
local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$method" "$BASE$path")
|
||||||
|
[ "$token" != "-" ] && args+=(-H "authorization: Bearer $token")
|
||||||
|
[ -n "$body" ] && args+=(-H 'content-type: application/json' -d "$body")
|
||||||
|
curl "${args[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# `expect <code> <method> <path> <token> [body] -- <description>`
|
||||||
|
expect() {
|
||||||
|
local want="$1"; shift
|
||||||
|
local method="$1" path="$2" token="$3"; shift 3
|
||||||
|
local body=""
|
||||||
|
if [ "${1:-}" != "--" ]; then body="$1"; shift; fi
|
||||||
|
shift # the --
|
||||||
|
local what="$*"
|
||||||
|
local got; got="$(req "$method" "$path" "$token" "$body")"
|
||||||
|
if [ "$got" = "$want" ]; then
|
||||||
|
ok "$what ($got)"
|
||||||
|
else
|
||||||
|
bad "$what: expected $want, got $got - $(head -c 300 "$WORK/last.json")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
body_has() { grep -q "$1" "$WORK/last.json" || bad "expected '$1' in: $(head -c 300 "$WORK/last.json")"; }
|
||||||
|
body_lacks() { grep -q "$1" "$WORK/last.json" && bad "'$1' leaked into: $(head -c 300 "$WORK/last.json")" || true; }
|
||||||
|
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; }
|
||||||
|
|
||||||
|
# --- 0. preconditions ------------------------------------------------------
|
||||||
|
|
||||||
|
echo "==> checking the database is up and migrated"
|
||||||
|
psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up"
|
||||||
|
psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \
|
||||||
|
|| fail "farms.org_id is missing - run scripts/dev-env.sh schema"
|
||||||
|
psql_q "SELECT role FROM org_members LIMIT 0" >/dev/null 2>&1 \
|
||||||
|
|| fail "org_members is missing - run scripts/dev-env.sh schema"
|
||||||
|
ok "schema has the org layer"
|
||||||
|
|
||||||
|
# --- 1. the issuer and the backend ----------------------------------------
|
||||||
|
|
||||||
|
echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT"
|
||||||
|
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
|
||||||
|
AUTH_PID=$!
|
||||||
|
AUTH_URL="http://localhost:$AUTH_PORT"
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \
|
||||||
|
|| fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")"
|
||||||
|
export SUPABASE_URL="$AUTH_URL"
|
||||||
|
ok "issuer up, SUPABASE_URL=$SUPABASE_URL"
|
||||||
|
|
||||||
|
mint() { curl -sf "$AUTH_URL/token?sub=$1" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; }
|
||||||
|
|
||||||
|
|
||||||
|
echo "==> starting continuum-backend on :$PORT"
|
||||||
|
bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
curl -sf "$BASE/health" >/dev/null 2>&1 && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
curl -sf "$BASE/health" >/dev/null 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")"
|
||||||
|
ok "backend healthy"
|
||||||
|
|
||||||
|
# --- 2. a farm with one idle printer and a stand-in gateway ----------------
|
||||||
|
|
||||||
|
OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")"
|
||||||
|
expect 201 POST /orgs "$OWNER" '{"name":"e2e job-flow org"}' -- "create an org"
|
||||||
|
ORG_A="$(json 'd["id"]')"
|
||||||
|
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e job-flow farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm"
|
||||||
|
FARM_A="$(json 'd["id"]')"
|
||||||
|
expect 201 POST /printers "$OWNER" \
|
||||||
|
"{\"farmId\":\"$FARM_A\",\"name\":\"Fake P1\",\"vendor\":\"bambu\",\"gatewayPrinterId\":\"fakep\"}" -- "claim a printer"
|
||||||
|
PRINTER_A="$(json 'd["id"]')"
|
||||||
|
expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER" -- "mint the gateway token"
|
||||||
|
PROXY_TOKEN="$(json 'd["edgeProxyToken"]')"
|
||||||
|
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$PROXY_TOKEN\"}" -- "trade it for an edge JWT"
|
||||||
|
EDGE_JWT="$(json 'd["token"]')"
|
||||||
|
FRAMES="$WORK/frames.jsonl"; : > "$FRAMES"
|
||||||
|
FARM_ID="$FARM_A" REPORT_IDLE=fakep bun scripts/fake-gateway-serve.ts "ws://localhost:$PORT/ws/edge/v1" "$EDGE_JWT" 90 "$FRAMES" > "$WORK/gw.log" 2>&1 &
|
||||||
|
GW_PID=$!
|
||||||
|
for _ in $(seq 1 40); do grep -q ready "$WORK/gw.log" 2>/dev/null && break; sleep 0.25; done
|
||||||
|
grep -q ready "$WORK/gw.log" || fail "the stand-in gateway did not connect: $(cat "$WORK/gw.log")"
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRINTER_A'")" = "idle" ] && break
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRINTER_A'")" = "idle" ] && ok "the printer reports idle" || fail "the printer never reported idle"
|
||||||
|
|
||||||
|
# --- 3. upload, dispatch, print --------------------------------------------
|
||||||
|
|
||||||
|
echo "==> upload a file through the presigned URL"
|
||||||
|
head -c 200000 /dev/urandom > "$WORK/cube.3mf"
|
||||||
|
WANT="$(sha256sum "$WORK/cube.3mf" | cut -d' ' -f1)"
|
||||||
|
expect 201 POST /jobs "$OWNER" "{\"farmId\":\"$FARM_A\",\"fileName\":\"cube.3mf\",\"contentType\":\"application/octet-stream\"}" -- "queue a job"
|
||||||
|
JOB_A="$(json 'd["job"]["id"]')"
|
||||||
|
OBJECT_KEY="$(json 'd["job"]["r2ObjectKey"]')"
|
||||||
|
UPLOAD_URL="$(json 'd["uploadUrl"]')"
|
||||||
|
cleanup_object() { [ -n "${OBJECT_KEY:-}" ] && bun -e "
|
||||||
|
import { s3 } from './src/s3';
|
||||||
|
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
await s3.send(new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET ?? 'continuum', Key: process.argv[1] }));
|
||||||
|
" "$OBJECT_KEY" >/dev/null 2>&1 || true; }
|
||||||
|
trap 'cleanup_object; cleanup' EXIT
|
||||||
|
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/octet-stream' --data-binary @"$WORK/cube.3mf" "$UPLOAD_URL")"
|
||||||
|
[ "$CODE" = "200" ] && ok "the file reached the object store (HTTP 200)" || fail "the upload returned HTTP $CODE"
|
||||||
|
|
||||||
|
echo "==> the scheduler dispatches it and the print runs to completion"
|
||||||
|
SEEN=""
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
STATUS="$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_A'")"
|
||||||
|
case " $SEEN " in *" $STATUS "*) ;; *) SEEN="$SEEN $STATUS" ;; esac
|
||||||
|
[ "$STATUS" = "completed" ] && break
|
||||||
|
[ "$STATUS" = "failed" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ "$STATUS" = "completed" ] && ok "the job completed (saw:$SEEN)" || bad "the job ended as '$STATUS' (saw:$SEEN)"
|
||||||
|
case "$SEEN" in *printing*) ok "it was seen printing on the way" ;; *) bad "the job was never printing: $SEEN" ;; esac
|
||||||
|
|
||||||
|
python3 - "$FRAMES" "$WANT" <<'PYCHK' && ok "the gateway got start_print for this job, and downloaded exactly the bytes that were uploaded" || bad "the gateway's view does not match: $(cat "$FRAMES")"
|
||||||
|
import json,sys
|
||||||
|
frames=[json.loads(l) for l in open(sys.argv[1])]
|
||||||
|
start=[f for f in frames if f["type"]=="start_print"]
|
||||||
|
dl=[f for f in frames if f["type"]=="downloaded"]
|
||||||
|
assert len(start)==1, f"expected one start_print, got {len(start)}"
|
||||||
|
assert start[0]["file_name"]=="cube.3mf" and start[0]["printer_id"]=="fakep", start[0]
|
||||||
|
assert len(dl)==1 and dl[0]["sha256"]==sys.argv[2] and dl[0]["bytes"]==200000, dl
|
||||||
|
PYCHK
|
||||||
|
|
||||||
|
kill "$GW_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAILURES" -eq 0 ]; then
|
||||||
|
echo "PASS - a file went from upload to a finished print, byte for byte, through every real component but the printer."
|
||||||
|
else
|
||||||
|
echo "FAIL - $FAILURES assertion(s) failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+154
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Onboarding end to end, against the real routes and database: inviting
|
||||||
|
# someone by email, and connecting a gateway with a one-time code instead of
|
||||||
|
# copying a token. With the proxy built, the last step runs the real
|
||||||
|
# `continuum-proxy enrol` against the live backend and starts the proxy on
|
||||||
|
# what it wrote.
|
||||||
|
#
|
||||||
|
# Nothing here sends an email: the invitation link is returned to the inviter
|
||||||
|
# (see the invitations route).
|
||||||
|
#
|
||||||
|
# scripts/dev-env.sh up && scripts/dev-env.sh schema
|
||||||
|
# scripts/e2e-onboarding.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
PORT="${PORT:-3064}"; AUTH_PORT="${AUTH_PORT:-3065}"
|
||||||
|
BASE="http://localhost:$PORT"
|
||||||
|
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||||
|
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||||
|
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||||
|
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
|
||||||
|
export R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}" R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}" R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
BACKEND_PID=""; AUTH_PID=""; PROXY_PID=""; ORG_ID=""; FAILURES=0
|
||||||
|
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
|
||||||
|
new_uuid() { cat /proc/sys/kernel/random/uuid; }
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
ok() { echo " ok: $*"; }
|
||||||
|
bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); }
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done
|
||||||
|
sleep 0.5
|
||||||
|
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done
|
||||||
|
[ -z "${KEEP_DATA:-}" ] && [ -n "$ORG_ID" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
|
||||||
|
if [ $rc -ne 0 ]; then echo "--- backend log (tail) ---"; tail -20 "$WORK/backend.log" 2>/dev/null || true; fi
|
||||||
|
rm -rf "$WORK"; exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
req() { # method path token [json] -> status; body in $WORK/last.json
|
||||||
|
local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$1" "$BASE$2")
|
||||||
|
[ "$3" != "-" ] && args+=(-H "authorization: Bearer $3")
|
||||||
|
[ -n "${4:-}" ] && args+=(-H 'content-type: application/json' -d "$4")
|
||||||
|
curl "${args[@]}"
|
||||||
|
}
|
||||||
|
expect() { # code method path token [body] -- what
|
||||||
|
local want="$1" method="$2" path="$3" token="$4"; shift 4
|
||||||
|
local body=""; [ "${1:-}" != "--" ] && { body="$1"; shift; }; shift
|
||||||
|
local got; got="$(req "$method" "$path" "$token" "$body")"
|
||||||
|
[ "$got" = "$want" ] && ok "$* ($got)" || bad "$*: expected $want, got $got - $(head -c 250 "$WORK/last.json")"
|
||||||
|
}
|
||||||
|
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; }
|
||||||
|
|
||||||
|
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
|
||||||
|
AUTH_PID=$!
|
||||||
|
AUTH_URL="http://localhost:$AUTH_PORT"
|
||||||
|
for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done
|
||||||
|
export SUPABASE_URL="$AUTH_URL"
|
||||||
|
mint() { curl -sf "$AUTH_URL/token?sub=$1&email=$2" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; }
|
||||||
|
DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && break; sleep 0.5; done
|
||||||
|
curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy"
|
||||||
|
|
||||||
|
OWNER_ID="$(new_uuid)"; GUEST_ID="$(new_uuid)"; STRANGER_ID="$(new_uuid)"
|
||||||
|
TAG="$(new_uuid | cut -c1-8)"
|
||||||
|
OWNER="$(mint "$OWNER_ID" "owner-$TAG@example.test")"
|
||||||
|
GUEST="$(mint "$GUEST_ID" "Guest-$TAG@Example.Test")"
|
||||||
|
STRANGER="$(mint "$STRANGER_ID" "stranger-$TAG@example.test")"
|
||||||
|
|
||||||
|
echo "==> an organisation and a farm"
|
||||||
|
expect 201 POST /orgs "$OWNER" '{"name":"e2e onboarding org"}' -- "owner creates an org"
|
||||||
|
ORG_ID="$(json 'd["id"]')"
|
||||||
|
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e onboarding farm\",\"orgId\":\"$ORG_ID\"}" -- "owner creates a farm"
|
||||||
|
FARM_ID="$(json 'd["id"]')"
|
||||||
|
|
||||||
|
echo "==> inviting by email"
|
||||||
|
expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"guest-$TAG@example.test\",\"role\":\"operator\"}" -- "owner invites an address"
|
||||||
|
TOKEN="$(json 'd["token"]')"; INV_ID="$(json 'd["id"]')"
|
||||||
|
grep -q "$TOKEN" <<<"$(psql_q "SELECT token_hash FROM org_invitations WHERE id='$INV_ID'")" && bad "the token is stored in the clear" || ok "only a hash of the token is stored"
|
||||||
|
expect 404 POST "/orgs/$ORG_ID/invitations" "$GUEST" "{\"email\":\"x-$TAG@example.test\",\"role\":\"viewer\"}" -- "a non-member cannot invite (the org does not exist for them)"
|
||||||
|
expect 200 GET "/orgs/$ORG_ID/invitations" "$OWNER" -- "owner lists pending invitations"
|
||||||
|
[ "$(json 'len(d)')" = 1 ] && ok "one pending" || bad "pending list: $(cat "$WORK/last.json")"
|
||||||
|
grep -q "$TOKEN" "$WORK/last.json" && bad "the list leaks the token" || ok "the list does not show the token"
|
||||||
|
|
||||||
|
echo "==> accepting"
|
||||||
|
expect 403 POST /invitations/accept "$STRANGER" "{\"token\":\"$TOKEN\"}" -- "someone signed in with another address cannot use the link"
|
||||||
|
expect 404 POST /invitations/accept "$GUEST" '{"token":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}' -- "a made-up token is refused"
|
||||||
|
expect 200 POST /invitations/accept "$GUEST" "{\"token\":\"$TOKEN\"}" -- "the invited address accepts (case-insensitively)"
|
||||||
|
[ "$(json 'd["role"]')" = operator ] && ok "joined as operator" || bad "role: $(cat "$WORK/last.json")"
|
||||||
|
expect 404 POST /invitations/accept "$GUEST" "{\"token\":\"$TOKEN\"}" -- "the link works once"
|
||||||
|
expect 200 GET "/orgs/$ORG_ID/members" "$OWNER" -- "owner lists members"
|
||||||
|
grep -q "guest-$TAG@example.test" "$WORK/last.json" && ok "the member list shows the email" || bad "no email in $(cat "$WORK/last.json")"
|
||||||
|
expect 200 GET "/orgs/$ORG_ID" "$GUEST" -- "the new member can see the org"
|
||||||
|
|
||||||
|
echo "==> expiry and revocation"
|
||||||
|
expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"stranger-$TAG@example.test\",\"role\":\"viewer\"}" -- "a second invitation"
|
||||||
|
T2="$(json 'd["token"]')"; I2="$(json 'd["id"]')"
|
||||||
|
psql_q "UPDATE org_invitations SET expires_at = now() - interval '1 minute' WHERE id='$I2'" >/dev/null
|
||||||
|
expect 404 POST /invitations/accept "$STRANGER" "{\"token\":\"$T2\"}" -- "an expired invitation is refused"
|
||||||
|
expect 201 POST "/orgs/$ORG_ID/invitations" "$OWNER" "{\"email\":\"stranger-$TAG@example.test\",\"role\":\"viewer\"}" -- "re-inviting issues a fresh one"
|
||||||
|
T3="$(json 'd["token"]')"; I3="$(json 'd["id"]')"
|
||||||
|
expect 200 DELETE "/orgs/$ORG_ID/invitations/$I3" "$OWNER" -- "owner revokes it"
|
||||||
|
expect 404 POST /invitations/accept "$STRANGER" "{\"token\":\"$T3\"}" -- "a revoked invitation is refused"
|
||||||
|
|
||||||
|
echo "==> enrolling a gateway with a one-time code"
|
||||||
|
expect 403 POST "/farms/$FARM_ID/enrolment-codes" "$GUEST" -- "an operator cannot make an enrolment code"
|
||||||
|
expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "owner makes a code"
|
||||||
|
CODE="$(json 'd["code"]')"
|
||||||
|
[ "$(psql_q "SELECT count(*) FROM enrolment_codes WHERE code_hash = '$CODE'")" = 0 ] && ok "the code is not stored in the clear" || bad "code stored in the clear"
|
||||||
|
TYPED="$(echo "$CODE" | tr 'A-Z' 'a-z' | tr -d '-')"
|
||||||
|
expect 200 POST /auth/enrol - "{\"code\":\"$TYPED\"}" -- "the gateway trades the code (typed sloppily)"
|
||||||
|
GW_TOKEN="$(json 'd["gatewayToken"]')"
|
||||||
|
[ "$(json 'd["farmId"]')" = "$FARM_ID" ] && ok "it is told which farm" || bad "farm: $(cat "$WORK/last.json")"
|
||||||
|
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_ID\",\"edgeProxyToken\":\"$GW_TOKEN\"}" -- "the credential it got works"
|
||||||
|
expect 401 POST /auth/enrol - "{\"code\":\"$CODE\"}" -- "a code works once"
|
||||||
|
expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a second code"
|
||||||
|
C2="$(json 'd["code"]')"
|
||||||
|
expect 200 POST /auth/enrol - "{\"code\":\"$C2\"}" -- "enrolling a second machine"
|
||||||
|
[ "$(json 'd["gatewayToken"]')" = "$GW_TOKEN" ] && ok "the farm's token was not rotated, so the first gateway still works" || bad "the token changed"
|
||||||
|
expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a third code, to expire"
|
||||||
|
C3="$(json 'd["code"]')"
|
||||||
|
psql_q "UPDATE enrolment_codes SET expires_at = now() - interval '1 minute' WHERE farm_id='$FARM_ID' AND used_at IS NULL" >/dev/null
|
||||||
|
expect 401 POST /auth/enrol - "{\"code\":\"$C3\"}" -- "an expired code is refused"
|
||||||
|
|
||||||
|
if [ -x "$PROXY_DIR/target/debug/continuum-proxy" ] && "$PROXY_DIR/target/debug/continuum-proxy" enrol --help >/dev/null 2>&1; then
|
||||||
|
echo "==> the real proxy enrols itself"
|
||||||
|
expect 201 POST "/farms/$FARM_ID/enrolment-codes" "$OWNER" -- "a code for the proxy"
|
||||||
|
PCODE="$(json 'd["code"]')"
|
||||||
|
CONF="$WORK/proxy.toml"
|
||||||
|
( cd "$WORK" && CONTINUUM_CONFIG_PATH="$CONF" "$PROXY_DIR/target/debug/continuum-proxy" enrol "$BASE" "$PCODE" > "$WORK/enrol.log" 2>&1 ) || { cat "$WORK/enrol.log"; fail "proxy enrol failed"; }
|
||||||
|
[ "$(stat -c %a "$CONF")" = 600 ] && ok "the config it wrote is owner-only" || bad "config mode $(stat -c %a "$CONF")"
|
||||||
|
grep -q "$PCODE" "$WORK/enrol.log" && bad "the code is in the proxy output" || ok "the code is not echoed"
|
||||||
|
grep -q "gateway_token" "$CONF" && grep -q "farm_id = \"$FARM_ID\"" "$CONF" && ok "farm and token are in the config" || bad "config: $(sed 's/gateway_token.*/gateway_token = <hidden>/' "$CONF")"
|
||||||
|
env -C "$PROXY_DIR" CONTINUUM_CONFIG_PATH="$CONF" CONTINUUM_GO2RTC_BIN=/nonexistent CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
||||||
|
CONTINUUM_PRINTERS_PATH="$WORK/none.toml" CONTINUUM_STORE_PATH="$WORK/store/continuum.db" RUST_LOG=info \
|
||||||
|
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
|
||||||
|
PROXY_PID=$!
|
||||||
|
for _ in $(seq 1 40); do grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && break; sleep 0.5; done
|
||||||
|
grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && ok "it started on that config alone and the cloud accepted it" || bad "the enrolled proxy never connected: $(tail -5 "$WORK/proxy.log")"
|
||||||
|
else
|
||||||
|
echo " (skipping the proxy step: build continuum-proxy with the enrol command first)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Last, because it blocks this address from enrolling for ten minutes.
|
||||||
|
echo "==> guessing"
|
||||||
|
for i in $(seq 1 12); do req POST /auth/enrol - '{"code":"ZZZZZ-ZZZZZ"}' >/dev/null; done
|
||||||
|
expect 429 POST /auth/enrol - '{"code":"ZZZZZ-ZZZZZ"}' -- "guessing is rate-limited"
|
||||||
|
|
||||||
|
echo
|
||||||
|
[ "$FAILURES" -eq 0 ] && echo "PASS - invitations and one-time-code enrolment." || { echo "FAIL - $FAILURES assertion(s)." >&2; exit 1; }
|
||||||
Executable
+277
@@ -0,0 +1,277 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end test of adding a printer with the REAL continuum-proxy as the
|
||||||
|
# gateway (e2e-provision.sh uses a stand-in): the provision frame must parse on
|
||||||
|
# the proxy, the proxy must try the printer, and the answer must come back.
|
||||||
|
#
|
||||||
|
# Without REAL_HOST/REAL_SERIAL/REAL_ACCESS_CODE it points the add at a closed
|
||||||
|
# port on this machine and proves the failure path across the real wire: a
|
||||||
|
# useful error comes back, no printer row is created, no store entry is made,
|
||||||
|
# and the proxy's log never contains the access code.
|
||||||
|
#
|
||||||
|
# WITH them (a printer you are willing to connect to - this only READS, it
|
||||||
|
# sends no command) it proves the success path as well: 201, telemetry flows
|
||||||
|
# under the new id, the proxy's store file is owner-only, and after the proxy
|
||||||
|
# is restarted with NO printers file the printer comes back from the store.
|
||||||
|
# REAL_HOST=192.168.0.20 REAL_SERIAL=... REAL_ACCESS_CODE=... scripts/e2e-provision-proxy.sh
|
||||||
|
#
|
||||||
|
# This is the test that the unit tests cannot be. tests/authz.test.ts proves
|
||||||
|
# tokens are verified and that the role ladder orders correctly; only this
|
||||||
|
# proves that each route actually asks, that scoping is applied in the query
|
||||||
|
# rather than after it, and that credentials stay out of responses - the three
|
||||||
|
# things that were wrong when every route was unauthenticated.
|
||||||
|
#
|
||||||
|
# Four users, two organisations, no printers or gateways needed:
|
||||||
|
# owner_a, operator_a, viewer_a in org A, which owns farm A and printer A
|
||||||
|
# outsider_b in org B, which owns nothing
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/dev-env.sh up && scripts/dev-env.sh schema
|
||||||
|
# scripts/e2e-authz.sh
|
||||||
|
#
|
||||||
|
# KEEP_DATA=1 leave the seeded organisations behind for inspection
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
PORT="${PORT:-3034}"
|
||||||
|
AUTH_PORT="${AUTH_PORT:-3035}"
|
||||||
|
BASE="http://localhost:$PORT"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||||
|
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||||
|
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||||
|
# Presigning a PUT is local HMAC with no network call, so POST /jobs works
|
||||||
|
# offline - but the SDK refuses to sign with empty credentials.
|
||||||
|
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}"
|
||||||
|
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}"
|
||||||
|
R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}"
|
||||||
|
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
|
||||||
|
export R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
BACKEND_PID=""
|
||||||
|
PROXY_PID=""
|
||||||
|
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
|
||||||
|
AUTH_PID=""
|
||||||
|
FAILURES=0
|
||||||
|
|
||||||
|
# `uuidgen` is not installed everywhere; the kernel's generator always is.
|
||||||
|
new_uuid() {
|
||||||
|
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||||
|
cat /proc/sys/kernel/random/uuid
|
||||||
|
else
|
||||||
|
python3 -c 'import uuid; print(uuid.uuid4())'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uses DATABASE_URL rather than a hardcoded localhost. The backend under test
|
||||||
|
# reads that variable, so a psql pointed anywhere else seeds fixtures into a
|
||||||
|
# different database than the one being exercised - and the failure message
|
||||||
|
# below names DATABASE_URL, which would then be naming a URL nothing tried.
|
||||||
|
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
# Never `kill $PID` with an unset PID: `kill 0` signals the whole process
|
||||||
|
# group, which includes this script and whatever launched it.
|
||||||
|
local pids=()
|
||||||
|
[ -n "$BACKEND_PID" ] && pids+=("$BACKEND_PID")
|
||||||
|
[ -n "$AUTH_PID" ] && pids+=("$AUTH_PID")
|
||||||
|
[ -n "$PROXY_PID" ] && pids+=("$PROXY_PID")
|
||||||
|
if [ ${#pids[@]} -gt 0 ]; then
|
||||||
|
kill "${pids[@]}" 2>/dev/null || true
|
||||||
|
sleep 0.3
|
||||||
|
kill -9 "${pids[@]}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -z "${KEEP_DATA:-}" ]; then
|
||||||
|
for org in "${ORG_A:-}" "${ORG_B:-}"; do
|
||||||
|
[ -n "$org" ] && psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo
|
||||||
|
echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true
|
||||||
|
echo "--- proxy log (tail, credentials are never logged) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
ok() { echo " ok: $*"; }
|
||||||
|
bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); }
|
||||||
|
|
||||||
|
# `req <method> <path> <token|-> [json body]` -> prints the status code,
|
||||||
|
# leaves the response body in $WORK/last.json.
|
||||||
|
req() {
|
||||||
|
local method="$1" path="$2" token="$3" body="${4:-}"
|
||||||
|
local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$method" "$BASE$path")
|
||||||
|
[ "$token" != "-" ] && args+=(-H "authorization: Bearer $token")
|
||||||
|
[ -n "$body" ] && args+=(-H 'content-type: application/json' -d "$body")
|
||||||
|
curl "${args[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# `expect <code> <method> <path> <token> [body] -- <description>`
|
||||||
|
expect() {
|
||||||
|
local want="$1"; shift
|
||||||
|
local method="$1" path="$2" token="$3"; shift 3
|
||||||
|
local body=""
|
||||||
|
if [ "${1:-}" != "--" ]; then body="$1"; shift; fi
|
||||||
|
shift # the --
|
||||||
|
local what="$*"
|
||||||
|
local got; got="$(req "$method" "$path" "$token" "$body")"
|
||||||
|
if [ "$got" = "$want" ]; then
|
||||||
|
ok "$what ($got)"
|
||||||
|
else
|
||||||
|
bad "$what: expected $want, got $got - $(head -c 300 "$WORK/last.json")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
body_has() { grep -q "$1" "$WORK/last.json" || bad "expected '$1' in: $(head -c 300 "$WORK/last.json")"; }
|
||||||
|
body_lacks() { grep -q "$1" "$WORK/last.json" && bad "'$1' leaked into: $(head -c 300 "$WORK/last.json")" || true; }
|
||||||
|
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; }
|
||||||
|
|
||||||
|
# --- 0. preconditions ------------------------------------------------------
|
||||||
|
|
||||||
|
echo "==> checking the database is up and migrated"
|
||||||
|
psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up"
|
||||||
|
psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \
|
||||||
|
|| fail "farms.org_id is missing - run scripts/dev-env.sh schema"
|
||||||
|
psql_q "SELECT role FROM org_members LIMIT 0" >/dev/null 2>&1 \
|
||||||
|
|| fail "org_members is missing - run scripts/dev-env.sh schema"
|
||||||
|
ok "schema has the org layer"
|
||||||
|
|
||||||
|
# --- 1. the issuer and the backend ----------------------------------------
|
||||||
|
|
||||||
|
echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT"
|
||||||
|
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
|
||||||
|
AUTH_PID=$!
|
||||||
|
AUTH_URL="http://localhost:$AUTH_PORT"
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \
|
||||||
|
|| fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")"
|
||||||
|
export SUPABASE_URL="$AUTH_URL"
|
||||||
|
ok "issuer up, SUPABASE_URL=$SUPABASE_URL"
|
||||||
|
|
||||||
|
mint() { curl -sf "$AUTH_URL/token?sub=$1" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; }
|
||||||
|
|
||||||
|
|
||||||
|
echo "==> starting continuum-backend on :$PORT"
|
||||||
|
DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
curl -sf "$BASE/health" >/dev/null 2>&1 && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
curl -sf "$BASE/health" >/dev/null 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")"
|
||||||
|
ok "backend healthy"
|
||||||
|
|
||||||
|
# --- 2. a farm and a real gateway ----------------------------------------
|
||||||
|
|
||||||
|
OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")"
|
||||||
|
expect 201 POST /orgs "$OWNER" '{"name":"e2e provision-proxy org"}' -- "create an org"
|
||||||
|
ORG_A="$(json 'd["id"]')"
|
||||||
|
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e provision-proxy farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm"
|
||||||
|
FARM_A="$(json 'd["id"]')"
|
||||||
|
expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER" -- "mint the gateway token"
|
||||||
|
PROXY_TOKEN="$(json 'd["edgeProxyToken"]')"
|
||||||
|
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$PROXY_TOKEN\"}" -- "trade it for an edge JWT"
|
||||||
|
EDGE_JWT="$(json 'd["token"]')"
|
||||||
|
|
||||||
|
echo "==> building and starting the real continuum-proxy with NO printers"
|
||||||
|
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
|
||||||
|
STORE="$WORK/proxy-store/continuum.db"
|
||||||
|
: > "$WORK/proxy.log"
|
||||||
|
start_proxy() {
|
||||||
|
# env -C so $! is the daemon itself and cleanup does not orphan it.
|
||||||
|
env -C "$PROXY_DIR" \
|
||||||
|
CONTINUUM_GATEWAY_ID=e2e-gw \
|
||||||
|
CONTINUUM_FARM_ID="$FARM_A" \
|
||||||
|
CONTINUUM_GATEWAY_TOKEN="$PROXY_TOKEN" \
|
||||||
|
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
|
||||||
|
CONTINUUM_GO2RTC_BIN=/nonexistent-go2rtc \
|
||||||
|
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
||||||
|
CONTINUUM_PRINTERS_PATH="$WORK/no-such-printers.toml" \
|
||||||
|
CONTINUUM_STORE_PATH="$STORE" \
|
||||||
|
RUST_LOG=info \
|
||||||
|
"$PROXY_DIR/target/debug/continuum-proxy" >> "$WORK/proxy.log" 2>&1 &
|
||||||
|
PROXY_PID=$!
|
||||||
|
}
|
||||||
|
stop_proxy() {
|
||||||
|
[ -n "$PROXY_PID" ] && kill "$PROXY_PID" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
[ -n "$PROXY_PID" ] && kill -9 "$PROXY_PID" 2>/dev/null || true
|
||||||
|
PROXY_PID=""
|
||||||
|
}
|
||||||
|
wait_for_gateway() {
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
grep -q "gateway e2e-gw connected" "$WORK/backend.log" && return 0
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
start_proxy
|
||||||
|
wait_for_gateway || fail "the proxy did not connect to the backend: $(tail -5 "$WORK/proxy.log")"
|
||||||
|
ok "the proxy connected with no printers file at all, from its store alone"
|
||||||
|
[ -f "$STORE" ] && ok "and created its store at $STORE" || bad "no store file was created"
|
||||||
|
|
||||||
|
# --- 3. the failure path across the real wire ------------------------------
|
||||||
|
|
||||||
|
echo "==> adding a printer that cannot be reached"
|
||||||
|
DEAD_CODE="DEADCODE-$(new_uuid | cut -c1-8)"
|
||||||
|
expect 502 POST "/farms/$FARM_A/printers" "$OWNER" \
|
||||||
|
"{\"name\":\"Nowhere\",\"vendor\":\"bambu\",\"host\":\"127.0.0.1\",\"accessCode\":\"$DEAD_CODE\",\"serialNumber\":\"01P00A000000099\"}" \
|
||||||
|
-- "the proxy reports it could not connect"
|
||||||
|
body_has GATEWAY_REFUSED
|
||||||
|
body_has "could not connect"
|
||||||
|
body_lacks "$DEAD_CODE"
|
||||||
|
N="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_A'")"
|
||||||
|
[ "$N" = "0" ] && ok "no printer row was created" || bad "a row exists after a failed add: $N"
|
||||||
|
grep -q "$DEAD_CODE" "$WORK/proxy.log" "$WORK/backend.log" && bad "the access code is in a log" || ok "the code is in neither the proxy's nor the backend's log"
|
||||||
|
if command -v sqlite3 >/dev/null 2>&1; then
|
||||||
|
[ "$(sqlite3 "$STORE" 'select count(*) from printers')" = "0" ] && ok "nothing was stored on the gateway" || bad "the proxy stored a printer it could not reach"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- 4. the success path, only with a printer you name ---------------------
|
||||||
|
|
||||||
|
if [ -n "${REAL_HOST:-}" ] && [ -n "${REAL_SERIAL:-}" ] && [ -n "${REAL_ACCESS_CODE:-}" ]; then
|
||||||
|
echo "==> adding the real printer at $REAL_HOST (read-only: connect and listen)"
|
||||||
|
expect 201 POST "/farms/$FARM_A/printers" "$OWNER" \
|
||||||
|
"{\"name\":\"Real printer\",\"vendor\":\"bambu\",\"host\":\"$REAL_HOST\",\"accessCode\":\"$REAL_ACCESS_CODE\",\"serialNumber\":\"$REAL_SERIAL\"}" \
|
||||||
|
-- "the proxy connected to the printer and the cloud created the row"
|
||||||
|
PRINTER_ID="$(json 'd["id"]')"
|
||||||
|
body_lacks "$REAL_ACCESS_CODE"
|
||||||
|
MODE="$(stat -c %a "$STORE")"
|
||||||
|
[ "$MODE" = "600" ] && ok "the store file is owner-only (600)" || bad "the store file mode is $MODE"
|
||||||
|
|
||||||
|
wait_telemetry() {
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
[ -n "$(psql_q "SELECT vendor_state FROM printers WHERE id='$PRINTER_ID' AND vendor_state IS NOT NULL")" ] && return 0
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
wait_telemetry && ok "telemetry arrives under the new id" || bad "no telemetry for the added printer"
|
||||||
|
|
||||||
|
echo "==> restart the proxy with no printers file: the printer must come back from the store"
|
||||||
|
psql_q "UPDATE printers SET vendor_state=NULL, last_seen_at=NULL WHERE id='$PRINTER_ID'" >/dev/null
|
||||||
|
stop_proxy
|
||||||
|
# The edge JWT is short-lived but outlives this test.
|
||||||
|
start_proxy
|
||||||
|
wait_telemetry && ok "after a restart the printer reports again, from the store alone" || bad "the printer did not come back after a restart"
|
||||||
|
grep -q "$REAL_ACCESS_CODE" "$WORK/proxy.log" "$WORK/backend.log" && bad "the access code is in a log" || ok "the access code is in no log"
|
||||||
|
else
|
||||||
|
echo " (skipping the success path: set REAL_HOST, REAL_SERIAL and REAL_ACCESS_CODE to run it against a real printer)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAILURES" -eq 0 ]; then
|
||||||
|
echo "PASS - the real proxy took provisioning requests over the real wire."
|
||||||
|
else
|
||||||
|
echo "FAIL - $FAILURES assertion(s) failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+246
@@ -0,0 +1,246 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end test of adding a printer from the console: the access code must
|
||||||
|
# reach the gateway over the real /ws/edge/v1 and be kept nowhere in the cloud
|
||||||
|
# - not in any table, not in the backend's log, not in a response.
|
||||||
|
#
|
||||||
|
# This is the test that the unit tests cannot be. tests/authz.test.ts proves
|
||||||
|
# tokens are verified and that the role ladder orders correctly; only this
|
||||||
|
# proves that each route actually asks, that scoping is applied in the query
|
||||||
|
# rather than after it, and that credentials stay out of responses - the three
|
||||||
|
# things that were wrong when every route was unauthenticated.
|
||||||
|
#
|
||||||
|
# Four users, two organisations, no printers or gateways needed:
|
||||||
|
# owner_a, operator_a, viewer_a in org A, which owns farm A and printer A
|
||||||
|
# outsider_b in org B, which owns nothing
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/dev-env.sh up && scripts/dev-env.sh schema
|
||||||
|
# scripts/e2e-authz.sh
|
||||||
|
#
|
||||||
|
# KEEP_DATA=1 leave the seeded organisations behind for inspection
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
PORT="${PORT:-3032}"
|
||||||
|
AUTH_PORT="${AUTH_PORT:-3033}"
|
||||||
|
BASE="http://localhost:$PORT"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||||
|
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||||
|
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||||
|
# Presigning a PUT is local HMAC with no network call, so POST /jobs works
|
||||||
|
# offline - but the SDK refuses to sign with empty credentials.
|
||||||
|
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}"
|
||||||
|
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}"
|
||||||
|
R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}"
|
||||||
|
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT
|
||||||
|
export R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
BACKEND_PID=""
|
||||||
|
AUTH_PID=""
|
||||||
|
FAILURES=0
|
||||||
|
|
||||||
|
# `uuidgen` is not installed everywhere; the kernel's generator always is.
|
||||||
|
new_uuid() {
|
||||||
|
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||||
|
cat /proc/sys/kernel/random/uuid
|
||||||
|
else
|
||||||
|
python3 -c 'import uuid; print(uuid.uuid4())'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uses DATABASE_URL rather than a hardcoded localhost. The backend under test
|
||||||
|
# reads that variable, so a psql pointed anywhere else seeds fixtures into a
|
||||||
|
# different database than the one being exercised - and the failure message
|
||||||
|
# below names DATABASE_URL, which would then be naming a URL nothing tried.
|
||||||
|
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
# Never `kill $PID` with an unset PID: `kill 0` signals the whole process
|
||||||
|
# group, which includes this script and whatever launched it.
|
||||||
|
local pids=()
|
||||||
|
[ -n "$BACKEND_PID" ] && pids+=("$BACKEND_PID")
|
||||||
|
[ -n "$AUTH_PID" ] && pids+=("$AUTH_PID")
|
||||||
|
if [ ${#pids[@]} -gt 0 ]; then
|
||||||
|
kill "${pids[@]}" 2>/dev/null || true
|
||||||
|
sleep 0.3
|
||||||
|
kill -9 "${pids[@]}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -z "${KEEP_DATA:-}" ]; then
|
||||||
|
for org in "${ORG_A:-}" "${ORG_B:-}"; do
|
||||||
|
[ -n "$org" ] && psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo
|
||||||
|
echo "--- backend log (tail) ---"; tail -30 "$WORK/backend.log" 2>/dev/null || true
|
||||||
|
echo "--- fake gateway log ---"; cat "$WORK/gw.log" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
ok() { echo " ok: $*"; }
|
||||||
|
bad() { echo " FAILED: $*" >&2; FAILURES=$((FAILURES + 1)); }
|
||||||
|
|
||||||
|
# `req <method> <path> <token|-> [json body]` -> prints the status code,
|
||||||
|
# leaves the response body in $WORK/last.json.
|
||||||
|
req() {
|
||||||
|
local method="$1" path="$2" token="$3" body="${4:-}"
|
||||||
|
local args=(-s -o "$WORK/last.json" -w '%{http_code}' -X "$method" "$BASE$path")
|
||||||
|
[ "$token" != "-" ] && args+=(-H "authorization: Bearer $token")
|
||||||
|
[ -n "$body" ] && args+=(-H 'content-type: application/json' -d "$body")
|
||||||
|
curl "${args[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# `expect <code> <method> <path> <token> [body] -- <description>`
|
||||||
|
expect() {
|
||||||
|
local want="$1"; shift
|
||||||
|
local method="$1" path="$2" token="$3"; shift 3
|
||||||
|
local body=""
|
||||||
|
if [ "${1:-}" != "--" ]; then body="$1"; shift; fi
|
||||||
|
shift # the --
|
||||||
|
local what="$*"
|
||||||
|
local got; got="$(req "$method" "$path" "$token" "$body")"
|
||||||
|
if [ "$got" = "$want" ]; then
|
||||||
|
ok "$what ($got)"
|
||||||
|
else
|
||||||
|
bad "$what: expected $want, got $got - $(head -c 300 "$WORK/last.json")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
body_has() { grep -q "$1" "$WORK/last.json" || bad "expected '$1' in: $(head -c 300 "$WORK/last.json")"; }
|
||||||
|
body_lacks() { grep -q "$1" "$WORK/last.json" && bad "'$1' leaked into: $(head -c 300 "$WORK/last.json")" || true; }
|
||||||
|
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)" < "$WORK/last.json"; }
|
||||||
|
|
||||||
|
# --- 0. preconditions ------------------------------------------------------
|
||||||
|
|
||||||
|
echo "==> checking the database is up and migrated"
|
||||||
|
psql_q "SELECT 1" >/dev/null 2>&1 || fail "no database at $DATABASE_URL - run scripts/dev-env.sh up"
|
||||||
|
psql_q "SELECT org_id FROM farms LIMIT 0" >/dev/null 2>&1 \
|
||||||
|
|| fail "farms.org_id is missing - run scripts/dev-env.sh schema"
|
||||||
|
psql_q "SELECT role FROM org_members LIMIT 0" >/dev/null 2>&1 \
|
||||||
|
|| fail "org_members is missing - run scripts/dev-env.sh schema"
|
||||||
|
ok "schema has the org layer"
|
||||||
|
|
||||||
|
# --- 1. the issuer and the backend ----------------------------------------
|
||||||
|
|
||||||
|
echo "==> starting the dev-auth JWKS issuer on :$AUTH_PORT"
|
||||||
|
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
|
||||||
|
AUTH_PID=$!
|
||||||
|
AUTH_URL="http://localhost:$AUTH_PORT"
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 \
|
||||||
|
|| fail "the dev-auth issuer did not come up: $(cat "$WORK/auth.log")"
|
||||||
|
export SUPABASE_URL="$AUTH_URL"
|
||||||
|
ok "issuer up, SUPABASE_URL=$SUPABASE_URL"
|
||||||
|
|
||||||
|
mint() { curl -sf "$AUTH_URL/token?sub=$1" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])'; }
|
||||||
|
|
||||||
|
|
||||||
|
echo "==> starting continuum-backend on :$PORT"
|
||||||
|
DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
curl -sf "$BASE/health" >/dev/null 2>&1 && break
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
curl -sf "$BASE/health" >/dev/null 2>&1 || fail "backend did not become healthy: $(cat "$WORK/backend.log")"
|
||||||
|
ok "backend healthy"
|
||||||
|
|
||||||
|
# --- 2. a farm, an owner, an operator -----------------------------------
|
||||||
|
|
||||||
|
OWNER_ID="$(new_uuid)"; OWNER="$(mint "$OWNER_ID")"
|
||||||
|
OPERATOR_ID="$(new_uuid)"; OPERATOR="$(mint "$OPERATOR_ID")"
|
||||||
|
OUTSIDER_ID="$(new_uuid)"; OUTSIDER="$(mint "$OUTSIDER_ID")"
|
||||||
|
expect 201 POST /orgs "$OWNER" '{"name":"e2e provision org"}' -- "create an org"
|
||||||
|
ORG_A="$(json 'd["id"]')"
|
||||||
|
expect 200 PUT "/orgs/$ORG_A/members/$OPERATOR_ID" "$OWNER" '{"role":"operator"}' -- "add an operator"
|
||||||
|
expect 201 POST /farms "$OWNER" "{\"name\":\"e2e provision farm\",\"orgId\":\"$ORG_A\"}" -- "create a farm"
|
||||||
|
FARM_A="$(json 'd["id"]')"
|
||||||
|
|
||||||
|
CODE="SECRETCODE-$(new_uuid | cut -c1-8)"
|
||||||
|
BODY="{\"name\":\"Bench P1P\",\"vendor\":\"bambu\",\"host\":\"192.168.0.50\",\"accessCode\":\"$CODE\",\"serialNumber\":\"01P00A000000001\"}"
|
||||||
|
|
||||||
|
echo "==> who may add a printer, and what happens with no gateway"
|
||||||
|
expect 403 POST "/farms/$FARM_A/printers" "$OPERATOR" "$BODY" -- "an operator cannot add a printer"
|
||||||
|
expect 404 POST "/farms/$FARM_A/printers" "$OUTSIDER" "$BODY" -- "an outsider cannot see the farm exists"
|
||||||
|
expect 401 POST "/farms/$FARM_A/printers" - "$BODY" -- "anonymous is refused"
|
||||||
|
expect 409 POST "/farms/$FARM_A/printers" "$OWNER" "$BODY" -- "no gateway connected: refused, nothing queued"
|
||||||
|
body_lacks "$CODE"
|
||||||
|
N="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_A'")"
|
||||||
|
[ "$N" = "0" ] && ok "no printer row was created" || bad "a row exists after a refused add: $N"
|
||||||
|
|
||||||
|
# --- 3. with a gateway -----------------------------------------------------
|
||||||
|
|
||||||
|
expect 200 POST "/farms/$FARM_A/edge-proxy-token" "$OWNER" -- "mint the gateway token"
|
||||||
|
PROXY_TOKEN="$(json 'd["edgeProxyToken"]')"
|
||||||
|
expect 200 POST /auth/edge-token - "{\"farmId\":\"$FARM_A\",\"edgeProxyToken\":\"$PROXY_TOKEN\"}" -- "trade it for an edge JWT"
|
||||||
|
EDGE_JWT="$(json 'd["token"]')"
|
||||||
|
FRAMES="$WORK/frames.jsonl"; : > "$FRAMES"
|
||||||
|
REFUSED="REFUSEME-$(new_uuid | cut -c1-8)"
|
||||||
|
FARM_ID="$FARM_A" bun scripts/fake-gateway-serve.ts "ws://localhost:$PORT/ws/edge/v1" "$EDGE_JWT" 40 "$FRAMES" "$REFUSED" > "$WORK/gw.log" 2>&1 &
|
||||||
|
GW_PID=$!
|
||||||
|
for _ in $(seq 1 40); do grep -q ready "$WORK/gw.log" 2>/dev/null && break; sleep 0.25; done
|
||||||
|
grep -q ready "$WORK/gw.log" || fail "the fake gateway did not connect: $(cat "$WORK/gw.log")"
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
echo "==> adding a printer through the gateway"
|
||||||
|
expect 422 POST "/farms/$FARM_A/printers" "$OWNER" "${BODY/bambu/klipper}" -- "a vendor with no working driver is refused"
|
||||||
|
expect 422 POST "/farms/$FARM_A/printers" "$OWNER" "${BODY/192.168.0.50/not a host}" -- "a malformed address is refused"
|
||||||
|
expect 201 POST "/farms/$FARM_A/printers" "$OWNER" "$BODY" -- "the owner adds a printer"
|
||||||
|
PRINTER_ID="$(json 'd["id"]')"
|
||||||
|
GWID="$(json 'd["gatewayPrinterId"]')"
|
||||||
|
body_lacks "$CODE"
|
||||||
|
ok "the response does not contain the access code (printer $PRINTER_ID, gateway id $GWID)"
|
||||||
|
[ "$(json 'd["lanAddress"]')" = "192.168.0.50" ] && ok "address recorded" || bad "lanAddress wrong"
|
||||||
|
|
||||||
|
GOT="$(python3 -c "
|
||||||
|
import json,sys
|
||||||
|
for l in open('$FRAMES'):
|
||||||
|
m=json.loads(l)
|
||||||
|
if m['type']=='provision_printer': print(m['printer']['access_code'], m['printer']['id'], m['printer']['sn'])
|
||||||
|
")"
|
||||||
|
[ "$GOT" = "$CODE $GWID 01P00A000000001" ] && ok "the gateway received the access code, its id, and the serial" || bad "gateway frame was: '$GOT'"
|
||||||
|
|
||||||
|
echo "==> the cloud kept the credential nowhere"
|
||||||
|
LEAK_ROWS="$(psql_q "SELECT count(*) FROM printers WHERE row_to_json(printers)::text LIKE '%$CODE%'")"
|
||||||
|
[ "$LEAK_ROWS" = "0" ] && ok "not in the printers table" || bad "the access code is in the printers table"
|
||||||
|
# Every table, so a future column or table holding it cannot hide.
|
||||||
|
ALL_LEAK="$(psql "$DATABASE_URL" -tAq -c "SELECT string_agg(table_name, ',') FROM information_schema.tables WHERE table_schema='public'" \
|
||||||
|
| tr ',' '\n' | while read -r tbl; do [ -n "$tbl" ] && psql_q "SELECT count(*) FROM \"$tbl\" t WHERE row_to_json(t)::text LIKE '%$CODE%'" | sed "s/^/$tbl /"; done | awk '$2>0')"
|
||||||
|
[ -z "$ALL_LEAK" ] && ok "not in any table of the database" || bad "found in: $ALL_LEAK"
|
||||||
|
grep -q "$CODE" "$WORK/backend.log" && bad "the access code is in the backend log" || ok "not in the backend's log"
|
||||||
|
|
||||||
|
echo "==> a gateway that refuses leaves nothing behind"
|
||||||
|
REFUSE_BODY="${BODY/$CODE/$REFUSED}"
|
||||||
|
expect 502 POST "/farms/$FARM_A/printers" "$OWNER" "$REFUSE_BODY" -- "the printer rejected the code"
|
||||||
|
body_has GATEWAY_REFUSED
|
||||||
|
body_has "access code was rejected"
|
||||||
|
body_lacks "$REFUSED"
|
||||||
|
N="$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_A'")"
|
||||||
|
[ "$N" = "1" ] && ok "still exactly one printer row" || bad "expected 1 printer row, found $N"
|
||||||
|
grep -q "$REFUSED" "$WORK/backend.log" && bad "the refused code is in the backend log" || ok "nor was the refused code logged"
|
||||||
|
|
||||||
|
echo "==> removing a printer tells the gateway"
|
||||||
|
expect 200 DELETE "/printers/$PRINTER_ID" "$OWNER" -- "the owner deletes the printer"
|
||||||
|
[ "$(json 'd["gatewayForgot"]')" = "True" ] && ok "the gateway confirmed it forgot the printer" || bad "gatewayForgot: $(cat "$WORK/last.json")"
|
||||||
|
grep -q "\"remove_printer\".*\"$GWID\"" "$FRAMES" && ok "the gateway received remove_printer for $GWID" || bad "no remove_printer frame for $GWID"
|
||||||
|
|
||||||
|
kill "$GW_PID" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAILURES" -eq 0 ]; then
|
||||||
|
echo "PASS - the access code reaches the gateway and rests nowhere in the cloud."
|
||||||
|
else
|
||||||
|
echo "FAIL - $FAILURES assertion(s) failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+137
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# The first-run experience in the real web console, in a real Chrome: a user
|
||||||
|
# with no organisation signs in, creates a farm, copies the gateway settings,
|
||||||
|
# connects a gateway configured from exactly those settings, adds a printer
|
||||||
|
# through the form, gets an alert when it fails, and removes it.
|
||||||
|
#
|
||||||
|
# The gateway is a stand-in (scripts/fake-gateway-serve.ts), so NO printer and
|
||||||
|
# no network device is touched; e2e-provision-proxy.sh covers the real proxy.
|
||||||
|
#
|
||||||
|
# SAFETY: nothing here talks to a real printer, and dispatch is off.
|
||||||
|
#
|
||||||
|
# Needs: source ../dev-secrets.sh first (real Supabase, as e2e-camera.sh's
|
||||||
|
# BROWSER mode), a migrated database, Chrome, ../continuum-app installed, and
|
||||||
|
# port 3042 free. Uses the throwaway Supabase user continuum-e2e@example.invalid,
|
||||||
|
# which must have no organisation (it refuses to start otherwise, so it cannot
|
||||||
|
# trample a SERVE=1 stack) and removes what it creates.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# set -a; . ../dev-secrets.sh; set +a
|
||||||
|
# scripts/e2e-setup-browser.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
PORT="${PORT:-3036}"
|
||||||
|
BASE="http://localhost:$PORT"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||||
|
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||||
|
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||||
|
R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}"
|
||||||
|
R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}"
|
||||||
|
R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}"
|
||||||
|
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
|
||||||
|
|
||||||
|
APP_DIR="${APP_DIR:-$REPO_ROOT/../continuum-app}"
|
||||||
|
APP_PORT="${APP_PORT:-3042}"
|
||||||
|
CHROME_BIN="${CHROME_BIN:-/usr/bin/google-chrome}"
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
BACKEND_PID=""
|
||||||
|
APP_PID=""
|
||||||
|
USER_ID=""
|
||||||
|
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
ok() { echo " ok: $*"; }
|
||||||
|
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
[ -n "$BACKEND_PID" ] && kill "$BACKEND_PID" 2>/dev/null || true
|
||||||
|
[ -n "$APP_PID" ] && kill -- "-$APP_PID" 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
[ -n "$BACKEND_PID" ] && kill -9 "$BACKEND_PID" 2>/dev/null || true
|
||||||
|
[ -n "$APP_PID" ] && kill -9 -- "-$APP_PID" 2>/dev/null || true
|
||||||
|
# What this run created, and only that: the user began with no organisation.
|
||||||
|
if [ -n "$USER_ID" ] && [ -z "${KEEP_DATA:-}" ]; then
|
||||||
|
psql_q "DELETE FROM organisations WHERE id IN (SELECT org_id FROM org_members WHERE user_id='$USER_ID')" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo; echo "--- backend log (tail) ---"; tail -20 "$WORK/backend.log" 2>/dev/null || true
|
||||||
|
echo "--- web console log (tail) ---"; tail -15 "$WORK/app.log" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
for name in SUPABASE_URL SUPABASE_SECRET_KEY SUPABASE_PUBLISHABLE_KEY; do
|
||||||
|
[ -n "${!name:-}" ] || fail "$name is not set - source ../dev-secrets.sh"
|
||||||
|
done
|
||||||
|
psql_q "SELECT 1 FROM printer_alerts LIMIT 0" >/dev/null 2>&1 || fail "no migrated database - run scripts/dev-env.sh up && schema"
|
||||||
|
[ -x "$CHROME_BIN" ] || fail "no Chrome at $CHROME_BIN"
|
||||||
|
[ -d "$APP_DIR/apps/web/node_modules" ] || fail "$APP_DIR has no installed web app"
|
||||||
|
# bash's own /dev/tcp rather than `ss`, which is not installed everywhere.
|
||||||
|
if (exec 3<>"/dev/tcp/127.0.0.1/$APP_PORT") 2>/dev/null; then fail "port $APP_PORT is already in use (a SERVE=1 stack running?)"; fi
|
||||||
|
|
||||||
|
SB_USER="$(bun scripts/e2e-supabase-user.ts)" || fail "could not provision the Supabase test user"
|
||||||
|
sb_field() { printf '%s' "$SB_USER" | python3 -c 'import json,sys; print(json.load(sys.stdin)[sys.argv[1]])' "$1"; }
|
||||||
|
USER_ID="$(sb_field id)"; SB_EMAIL="$(sb_field email)"; SB_PASSWORD="$(sb_field password)"
|
||||||
|
unset SB_USER
|
||||||
|
[ "$(psql_q "SELECT count(*) FROM org_members WHERE user_id='$USER_ID'")" = "0" ] \
|
||||||
|
|| fail "the test user already belongs to an organisation - stop any SERVE=1 stack first"
|
||||||
|
ok "a Supabase user with no organisation"
|
||||||
|
|
||||||
|
echo "==> starting the backend on :$PORT"
|
||||||
|
DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && break; sleep 0.5; done
|
||||||
|
curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy"
|
||||||
|
ok "backend healthy"
|
||||||
|
|
||||||
|
echo "==> starting the web console on :$APP_PORT"
|
||||||
|
setsid env -C "$APP_DIR/apps/web" \
|
||||||
|
NUXT_PUBLIC_API_BASE="$BASE" \
|
||||||
|
NUXT_PUBLIC_WS_BASE="ws://localhost:$PORT" \
|
||||||
|
SUPABASE_URL="$SUPABASE_URL" \
|
||||||
|
SUPABASE_KEY="$SUPABASE_PUBLISHABLE_KEY" \
|
||||||
|
NUXT_PUBLIC_PASSWORD_LOGIN=true \
|
||||||
|
pnpm exec nuxt dev --host 127.0.0.1 --port "$APP_PORT" > "$WORK/app.log" 2>&1 &
|
||||||
|
APP_PID=$!
|
||||||
|
for _ in $(seq 1 120); do curl -sf -o /dev/null "http://127.0.0.1:$APP_PORT/login" 2>/dev/null && break; sleep 1; done
|
||||||
|
curl -sf -o /dev/null "http://127.0.0.1:$APP_PORT/login" || fail "the web console did not start"
|
||||||
|
ok "the web console is up"
|
||||||
|
|
||||||
|
ACCESS_CODE="BROWSERCODE$(cat /proc/sys/kernel/random/uuid | cut -c1-6)"
|
||||||
|
FRAMES="$WORK/frames.jsonl"; : > "$FRAMES"
|
||||||
|
|
||||||
|
echo "==> first run, in real Chrome"
|
||||||
|
OUT="$(env APP_URL="http://127.0.0.1:$APP_PORT" API_BASE="$BASE" WS_BASE="ws://localhost:$PORT" \
|
||||||
|
SUPABASE_E2E_EMAIL="$SB_EMAIL" SUPABASE_E2E_PASSWORD="$SB_PASSWORD" CHROME_BIN="$CHROME_BIN" \
|
||||||
|
FRAMES_FILE="$FRAMES" ACCESS_CODE="$ACCESS_CODE" \
|
||||||
|
timeout 280 bun scripts/e2e-setup-browser.ts 2>&1 || true)"
|
||||||
|
seen=0; bad=0
|
||||||
|
while IFS=$'\t' read -r verdict label; do
|
||||||
|
case "$verdict" in
|
||||||
|
ok) ok "$label"; seen=$((seen + 1)) ;;
|
||||||
|
FAILED) echo " FAILED: $label" >&2; seen=$((seen + 1)); bad=$((bad + 1)) ;;
|
||||||
|
esac
|
||||||
|
done < <(printf '%s\n' "$OUT" | grep -E "^(ok|FAILED)$(printf '\t')")
|
||||||
|
if [ "$seen" -lt 18 ] && [ "$bad" -eq 0 ]; then
|
||||||
|
bad=1; echo " FAILED: the browser run produced only $seen of 18 results" >&2
|
||||||
|
fi
|
||||||
|
if [ "$bad" -ne 0 ]; then
|
||||||
|
printf '%s\n' "$OUT" | sed 's/^/ | /' >&2
|
||||||
|
fail "the first-run experience did not work"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The credential the browser typed must rest nowhere in the cloud.
|
||||||
|
grep -q "$ACCESS_CODE" "$WORK/backend.log" && fail "the access code is in the backend log"
|
||||||
|
LEAK="$(psql "$DATABASE_URL" -tAq -c "SELECT string_agg(table_name, ' ') FROM information_schema.tables WHERE table_schema='public'" \
|
||||||
|
| tr ' ' '\n' | while read -r tbl; do [ -n "$tbl" ] && psql_q "SELECT count(*) FROM \"$tbl\" t WHERE row_to_json(t)::text LIKE '%$ACCESS_CODE%'" | sed "s/^/$tbl /"; done | awk '$2>0')"
|
||||||
|
[ -z "$LEAK" ] || fail "the access code is stored in: $LEAK"
|
||||||
|
ok "the access code is in no table and no log"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "PASS - a new user went from nothing to a farm, a gateway, a printer, an alert and back."
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Drives the console's first-run experience in a real Chrome, for
|
||||||
|
* scripts/e2e-setup-browser.sh: a brand-new user with nothing signs in, creates
|
||||||
|
* a farm, gets gateway settings, connects a (stand-in) gateway with them, adds
|
||||||
|
* a printer through the form, sees an alert when it fails, dismisses it, and
|
||||||
|
* removes the printer.
|
||||||
|
*
|
||||||
|
* Results are `ok<TAB>label` / `FAILED<TAB>label`. The password, the gateway
|
||||||
|
* token and the printer's access code never reach a log line.
|
||||||
|
*
|
||||||
|
* Environment: APP_URL, API_BASE, WS_BASE, SUPABASE_E2E_EMAIL,
|
||||||
|
* SUPABASE_E2E_PASSWORD, FRAMES_FILE, ACCESS_CODE, CHROME_BIN.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import puppeteer from "puppeteer-core";
|
||||||
|
|
||||||
|
const { APP_URL, API_BASE, WS_BASE, FRAMES_FILE, ACCESS_CODE } = process.env as Record<string, string>;
|
||||||
|
const email = process.env.SUPABASE_E2E_EMAIL!;
|
||||||
|
const password = process.env.SUPABASE_E2E_PASSWORD!;
|
||||||
|
const chromeBin = process.env.CHROME_BIN ?? "/usr/bin/google-chrome";
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
const say = (good: boolean, label: string, detail = "") => {
|
||||||
|
if (!good) failed = true;
|
||||||
|
console.log(`${good ? "ok" : "FAILED"}\t${label}${detail ? ` (${detail})` : ""}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath: chromeBin,
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let gateway: ReturnType<typeof Bun.spawn> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
page.on("pageerror", (err) => pageErrors.push(String((err as Error).message ?? err).slice(0, 160)));
|
||||||
|
|
||||||
|
const waitText = (selector: string, text: string, timeout = 30_000) =>
|
||||||
|
page
|
||||||
|
.waitForFunction(
|
||||||
|
(sel: string, want: string) => document.querySelector(sel)?.textContent?.includes(want) ?? false,
|
||||||
|
{ timeout },
|
||||||
|
selector,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
await page.goto(`${APP_URL}/login`, { waitUntil: "domcontentloaded", timeout: 120_000 });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => typeof (window as any).useNuxtApp === "function" && Boolean((window as any).useNuxtApp().$supabase),
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
);
|
||||||
|
const signInError = await page.evaluate(
|
||||||
|
async (e: string, p: string) => {
|
||||||
|
const { client } = (window as any).useNuxtApp().$supabase;
|
||||||
|
const { error } = await client.auth.signInWithPassword({ email: e, password: p });
|
||||||
|
return error ? String(error.message) : null;
|
||||||
|
},
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
say(signInError === null, "a brand-new user signs in", signInError ?? "");
|
||||||
|
if (signInError) process.exit(1);
|
||||||
|
|
||||||
|
// 1. nothing yet -> pointed at Setup
|
||||||
|
await page.goto(`${APP_URL}/`, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||||
|
say(await waitText(".fleet", "You have no farm yet"), "the empty fleet page points a new user to Setup");
|
||||||
|
|
||||||
|
// 2. onboarding
|
||||||
|
await page.goto(`${APP_URL}/setup`, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||||
|
await page.waitForSelector(".onboarding input", { timeout: 30_000 });
|
||||||
|
await page.type(".onboarding input", "Browser Farm");
|
||||||
|
await page.click(".onboarding button[type=submit]");
|
||||||
|
say(await waitText(".setup__farm h2", "Browser Farm"), "creating a farm from nothing works (org and farm)");
|
||||||
|
|
||||||
|
// 3. gateway settings
|
||||||
|
await page.waitForSelector(".gateway-setup button", { timeout: 15_000 });
|
||||||
|
say(
|
||||||
|
await page.$eval(".gateway-setup", (el) => el.textContent?.includes("No gateway is connected") ?? false),
|
||||||
|
"setup says no gateway is connected yet",
|
||||||
|
);
|
||||||
|
await page.click(".gateway-setup button");
|
||||||
|
await page.waitForSelector(".gateway-setup__settings", { timeout: 15_000 });
|
||||||
|
const settings = await page.$eval(".gateway-setup__settings", (el) => el.textContent ?? "");
|
||||||
|
const farmId = /farm_id = "([^"]+)"/.exec(settings)?.[1] ?? "";
|
||||||
|
const proxyToken = /gateway_token = "([^"]+)"/.exec(settings)?.[1] ?? "";
|
||||||
|
const uplink = /uplink_url = "([^"]+)"/.exec(settings)?.[1] ?? "";
|
||||||
|
say(Boolean(farmId && proxyToken && uplink.endsWith("/ws/edge/v1")), "the page shows complete gateway settings");
|
||||||
|
|
||||||
|
// 4. a gateway configured from exactly what the page showed
|
||||||
|
const exchanged = await fetch(`${API_BASE}/auth/edge-token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ farmId, edgeProxyToken: proxyToken }),
|
||||||
|
});
|
||||||
|
say(exchanged.ok, "the token the page showed is accepted by the cloud");
|
||||||
|
const jwt = ((await exchanged.json()) as { token: string }).token;
|
||||||
|
gateway = Bun.spawn(["bun", "scripts/fake-gateway-serve.ts", `${WS_BASE}/ws/edge/v1`, jwt, "120", FRAMES_FILE], {
|
||||||
|
env: { ...process.env, FARM_ID: farmId, TELEMETRY_AFTER_PROVISION: "1" },
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
});
|
||||||
|
say(await waitText(".gateway-setup", "A gateway is connected", 30_000), "the page notices the gateway connecting");
|
||||||
|
await page.click(".gateway-setup button:last-of-type"); // Done: the token leaves the screen
|
||||||
|
say(
|
||||||
|
!(await page.$eval("body", (el, token) => el.textContent?.includes(token) ?? false, proxyToken)),
|
||||||
|
"dismissing the settings removes the token from the page",
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. add a printer
|
||||||
|
const fields = await page.$$(".add-printer form input");
|
||||||
|
await fields[0]!.type("Bench P1P");
|
||||||
|
await fields[1]!.type("192.168.0.77");
|
||||||
|
await fields[2]!.type("01P00A000000007");
|
||||||
|
await fields[3]!.type(ACCESS_CODE);
|
||||||
|
await page.click(".add-printer button[type=submit]");
|
||||||
|
say(await waitText(".add-printer", "Added Bench P1P", 45_000), "adding a printer through the form succeeds");
|
||||||
|
say(await waitText(".managed", "Bench P1P"), "the printer appears in the list");
|
||||||
|
const leftover = await page.$eval(".add-printer input[type=password]", (el) => (el as HTMLInputElement).value);
|
||||||
|
say(leftover === "", "the access code is cleared from the form after sending");
|
||||||
|
const frames = readFileSync(FRAMES_FILE, "utf8");
|
||||||
|
say(frames.includes(ACCESS_CODE), "the gateway received the access code");
|
||||||
|
|
||||||
|
// 6. the printer fails -> an alert on the fleet page
|
||||||
|
await page.goto(`${APP_URL}/`, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||||
|
say(await waitText(".alert-list", "the print failed", 60_000), "a failed print raises an alert on the fleet page");
|
||||||
|
await page.click(".alert-list button");
|
||||||
|
const gone = await page
|
||||||
|
.waitForFunction(() => document.querySelector(".alert-list") === null, { timeout: 20_000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
say(gone, "dismissing the alert removes it");
|
||||||
|
|
||||||
|
// 7. remove
|
||||||
|
await page.goto(`${APP_URL}/setup`, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
||||||
|
await page.waitForSelector(".managed button", { timeout: 20_000 });
|
||||||
|
await page.click(".managed button");
|
||||||
|
say(await waitText(".managed", "forget its access code"), "removing asks first, and says what it deletes");
|
||||||
|
const buttons = await page.$$(".managed button");
|
||||||
|
await buttons[0]!.click(); // "Yes, remove"
|
||||||
|
const removed = await page
|
||||||
|
.waitForFunction(() => document.querySelector(".managed") === null, { timeout: 30_000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
say(removed, "the printer is removed");
|
||||||
|
say(readFileSync(FRAMES_FILE, "utf8").includes("remove_printer"), "the gateway was told to forget it");
|
||||||
|
|
||||||
|
say(pageErrors.length === 0, "no uncaught page errors", pageErrors.slice(0, 2).join(" | "));
|
||||||
|
} finally {
|
||||||
|
gateway?.kill();
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
Executable
+171
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# The non-Bambu vendors end to end, against FAKE printers: add each one through
|
||||||
|
# the API (so the provision frame, the real proxy's driver and its store are
|
||||||
|
# all exercised), then run a job through the real scheduler and watch it go
|
||||||
|
# queued -> uploading -> printing -> completed from the fake's own telemetry.
|
||||||
|
#
|
||||||
|
# What this proves is the plumbing around the drivers. It does NOT prove the
|
||||||
|
# drivers speak the real Klipper / OctoPrint / PrusaLink protocols: the fake
|
||||||
|
# (scripts/fake-http-printer.ts) is written from the same reading of the docs
|
||||||
|
# as the driver, so a shared misreading passes here and fails on hardware.
|
||||||
|
#
|
||||||
|
# Needs the dev stack (scripts/dev-env.sh up && schema) and the object store
|
||||||
|
# credentials in ../dev-secrets.sh (the job file goes through the real
|
||||||
|
# presigned URLs). No real printer is touched.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
[ -f "$REPO_ROOT/../dev-secrets.sh" ] && . "$REPO_ROOT/../dev-secrets.sh"
|
||||||
|
|
||||||
|
KINDS="${KINDS:-klipper octoprint prusa_link}"
|
||||||
|
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
|
||||||
|
PORT="${PORT:-3062}"; AUTH_PORT="${AUTH_PORT:-3063}"; FAKE_PORT="${FAKE_PORT:-7141}"
|
||||||
|
BASE="http://localhost:$PORT"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||||
|
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||||
|
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||||
|
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
BACKEND_PID=""; PROXY_PID=""; AUTH_PID=""; FAKE_PID=""; ORGS=""
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
ok() { echo " ok: $*"; }
|
||||||
|
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
|
||||||
|
new_uuid() { cat /proc/sys/kernel/random/uuid; }
|
||||||
|
json() { python3 -c "import json,sys; d=json.load(sys.stdin); print($1)"; }
|
||||||
|
|
||||||
|
stop() { for pid in "$@"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done; sleep 0.5; for pid in "$@"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done; }
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
stop "$PROXY_PID" "$FAKE_PID" "$BACKEND_PID" "$AUTH_PID"
|
||||||
|
for org in $ORGS; do psql_q "DELETE FROM organisations WHERE id = '$org'" >/dev/null 2>&1 || true; done
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "--- backend log (tail) ---"; tail -25 "$WORK/backend.log" 2>/dev/null || true
|
||||||
|
echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true
|
||||||
|
echo "--- fake printer log (tail) ---"; tail -15 "$WORK/fake.log" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
[ -n "${KEEP_LOGS:-}" ] && cp "$WORK"/*.log "$KEEP_LOGS"/ 2>/dev/null || true
|
||||||
|
rm -rf "$WORK"; exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[ -n "${R2_ENDPOINT:-}" ] || fail "no object store credentials - ../dev-secrets.sh"
|
||||||
|
( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build"
|
||||||
|
|
||||||
|
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
|
||||||
|
AUTH_PID=$!
|
||||||
|
AUTH_URL="http://localhost:$AUTH_PORT"
|
||||||
|
for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done
|
||||||
|
export SUPABASE_URL="$AUTH_URL"
|
||||||
|
USER_ID="$(new_uuid)"
|
||||||
|
TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | json 'd["access_token"]')"
|
||||||
|
auth=(-H "authorization: Bearer $TOKEN")
|
||||||
|
|
||||||
|
start_backend() {
|
||||||
|
DISPATCH_ENABLED=true bun run src/index.ts >> "$WORK/backend.log" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && return 0; sleep 0.5; done
|
||||||
|
fail "backend did not become healthy"
|
||||||
|
}
|
||||||
|
start_backend
|
||||||
|
|
||||||
|
# run_kind <kind> <allow insecure downloads: true|false> [outage]
|
||||||
|
# With downloads refused the proxy accepts the start and then fails it, which
|
||||||
|
# is the late-failure path: the job must fail with the proxy's reason.
|
||||||
|
run_kind() {
|
||||||
|
local KIND="$1" ALLOW="$2" OUTAGE="${3:-}"
|
||||||
|
echo "==> $KIND (insecure local downloads: $ALLOW${OUTAGE:+, cloud outage mid-job})"
|
||||||
|
KEY="key-$(new_uuid | cut -c1-8)"
|
||||||
|
env ${OUTAGE:+RUN_S=14} bun scripts/fake-http-printer.ts "$KIND" "$FAKE_PORT" "$KEY" > "$WORK/fake.log" 2>&1 &
|
||||||
|
FAKE_PID=$!
|
||||||
|
for _ in $(seq 1 40); do grep -q listening "$WORK/fake.log" && break; sleep 0.25; done
|
||||||
|
|
||||||
|
ORG_ID="$(curl -sf "${auth[@]}" -H 'content-type: application/json' -d "{\"name\":\"e2e vendors $KIND\"}" "$BASE/orgs" | json 'd["id"]')"
|
||||||
|
ORGS="$ORGS $ORG_ID"
|
||||||
|
FARM_ID="$(curl -sf "${auth[@]}" -H 'content-type: application/json' -d "{\"name\":\"farm $KIND\",\"orgId\":\"$ORG_ID\"}" "$BASE/farms" | json 'd["id"]')"
|
||||||
|
GW_TOKEN="$(curl -sf "${auth[@]}" -X POST "$BASE/farms/$FARM_ID/edge-proxy-token" | json 'd["edgeProxyToken"]')"
|
||||||
|
|
||||||
|
env -C "$PROXY_DIR" \
|
||||||
|
CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS="$ALLOW" CONTINUUM_GATEWAY_ID=e2e-vendors-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$GW_TOKEN" \
|
||||||
|
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" CONTINUUM_GO2RTC_BIN=/nonexistent-go2rtc CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
||||||
|
CONTINUUM_PRINTERS_PATH="$WORK/no-such-printers.toml" CONTINUUM_STORE_PATH="$WORK/store-$KIND/continuum.db" RUST_LOG=info \
|
||||||
|
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
|
||||||
|
PROXY_PID=$!
|
||||||
|
# This farm's own connect line: the log is shared across the loop.
|
||||||
|
for _ in $(seq 1 60); do grep -q "connected for farm $FARM_ID" "$WORK/backend.log" && break; sleep 0.5; done
|
||||||
|
grep -q "connected for farm $FARM_ID" "$WORK/backend.log" || fail "the proxy never connected"
|
||||||
|
|
||||||
|
# Klipper needs no credential; the others take an API key.
|
||||||
|
CRED=""; [ "$KIND" != klipper ] && CRED=",\"apiKey\":\"$KEY\""
|
||||||
|
CODE="$(curl -s -o "$WORK/add.json" -w '%{http_code}' "${auth[@]}" -H 'content-type: application/json' \
|
||||||
|
-d "{\"name\":\"Fake $KIND\",\"vendor\":\"$KIND\",\"host\":\"127.0.0.1:$FAKE_PORT\"$CRED}" "$BASE/farms/$FARM_ID/printers")"
|
||||||
|
[ "$CODE" = 201 ] || fail "adding the $KIND printer returned $CODE: $(head -c 300 "$WORK/add.json")"
|
||||||
|
PRN_UUID="$(json 'd["id"]' < "$WORK/add.json")"
|
||||||
|
ok "added through the API and connected by the proxy"
|
||||||
|
grep -q "$KEY" "$WORK/proxy.log" "$WORK/backend.log" && fail "the API key is in a log"
|
||||||
|
ok "the API key is in neither log"
|
||||||
|
|
||||||
|
if [ "$KIND" != klipper ]; then
|
||||||
|
CODE="$(curl -s -o /dev/null -w '%{http_code}' "${auth[@]}" -H 'content-type: application/json' \
|
||||||
|
-d "{\"name\":\"No key\",\"vendor\":\"$KIND\",\"host\":\"127.0.0.1:$FAKE_PORT\"}" "$BASE/farms/$FARM_ID/printers")"
|
||||||
|
[ "$CODE" = 422 ] && ok "a missing API key is refused (422)" || fail "a keyless add returned $CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for _ in $(seq 1 30); do [ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = idle ] && break; sleep 1; done
|
||||||
|
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = idle ] || fail "the cloud never saw the printer idle"
|
||||||
|
ok "the cloud sees it idle (telemetry from the driver's poller)"
|
||||||
|
|
||||||
|
J="$(curl -sf "${auth[@]}" -H 'content-type: application/json' \
|
||||||
|
-d "{\"farmId\":\"$FARM_ID\",\"fileName\":\"e2e-part.gcode\",\"contentType\":\"application/octet-stream\"}" "$BASE/jobs")"
|
||||||
|
JOB_ID="$(printf '%s' "$J" | json 'd["job"]["id"]')"
|
||||||
|
OBJECT_KEY="$(printf '%s' "$J" | json 'd["job"]["r2ObjectKey"]')"
|
||||||
|
UPLOAD_URL="$(printf '%s' "$J" | json 'd["uploadUrl"]')"
|
||||||
|
printf 'G28\nG1 X10 Y10 F3000\nM104 S0\n' > "$WORK/part.gcode"
|
||||||
|
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/octet-stream' --data-binary @"$WORK/part.gcode" "$UPLOAD_URL")"
|
||||||
|
[ "$CODE" = 200 ] || fail "job file upload returned $CODE"
|
||||||
|
|
||||||
|
if [ -n "$OUTAGE" ]; then
|
||||||
|
# Take the cloud away the moment the job is dispatched - before any
|
||||||
|
# telemetry can have told it the print began - and bring it back only
|
||||||
|
# after the fake print is over. Telemetry alone would leave the job
|
||||||
|
# `uploading` (idle printer, never seen printing); only the gateway's
|
||||||
|
# own kept record can complete it.
|
||||||
|
for _ in $(seq 1 300); do [ "$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")" = uploading ] && break; sleep 0.1; done
|
||||||
|
[ "$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")" = uploading ] || fail "the job was not dispatched"
|
||||||
|
stop "$BACKEND_PID"; BACKEND_PID=""
|
||||||
|
echo " (cloud down; the fake print runs 14s without it)"
|
||||||
|
sleep 20
|
||||||
|
start_backend
|
||||||
|
fi
|
||||||
|
SEEN=""; START=$SECONDS; STATUS=""
|
||||||
|
while [ $((SECONDS - START)) -lt 60 ]; do
|
||||||
|
STATUS="$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")"
|
||||||
|
case " $SEEN " in *" $STATUS "*) ;; *) SEEN="$SEEN $STATUS" ;; esac
|
||||||
|
[ "$STATUS" = completed ] || [ "$STATUS" = failed ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " job status history:$SEEN"
|
||||||
|
if [ -n "$OUTAGE" ]; then
|
||||||
|
[ "$STATUS" = completed ] || fail "after the outage the $KIND job is '$STATUS', not completed"
|
||||||
|
grep -Eq "gateway reported (started|finished)" "$WORK/backend.log" || fail "it completed, but not from the gateway's kept record"
|
||||||
|
ok "the cloud was away for the whole print; the proxy's kept record completed the job on reconnect"
|
||||||
|
elif [ "$ALLOW" = true ]; then
|
||||||
|
[ "$STATUS" = completed ] || fail "the $KIND job ended as '$STATUS'"
|
||||||
|
ok "$KIND: queued, scheduled, uploaded, printed and completed (fake printer)"
|
||||||
|
else
|
||||||
|
[ "$STATUS" = failed ] || fail "the refused download left the job '$STATUS', not failed"
|
||||||
|
REASON="$(psql_q "SELECT metadata->>'lastDispatchError' FROM print_jobs WHERE id='$JOB_ID'")"
|
||||||
|
case "$REASON" in *"non-HTTPS"*) ok "the job failed at once with the proxy's reason: $REASON" ;; *) fail "the job failed without the proxy's reason: '$REASON'" ;; esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
bun -e "
|
||||||
|
import { s3 } from './src/s3';
|
||||||
|
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
await s3.send(new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET ?? 'continuum', Key: process.argv[1] }));
|
||||||
|
" "$OBJECT_KEY" >/dev/null 2>&1 || true
|
||||||
|
stop "$PROXY_PID" "$FAKE_PID"; PROXY_PID=""; FAKE_PID=""
|
||||||
|
}
|
||||||
|
for KIND in $KINDS; do run_kind "$KIND" true; done
|
||||||
|
run_kind klipper false
|
||||||
|
run_kind klipper true outage
|
||||||
|
echo "PASS - $KINDS: added from the API, scheduled and completed against fake printers; a refused download fails the job with its reason; a job finished while the cloud was away completes on reconnect."
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* A stand-in gateway that stays connected and answers printer provisioning,
|
||||||
|
* for scripts/e2e-provision.sh.
|
||||||
|
*
|
||||||
|
* With TELEMETRY_AFTER_PROVISION=1 it also reports each provisioned printer
|
||||||
|
* (running, then failed a few seconds later), so the console has something to
|
||||||
|
* alert on.
|
||||||
|
*
|
||||||
|
* With REPORT_IDLE=<gateway-printer-id> it reports that printer idle on
|
||||||
|
* connect, so the scheduler treats it as free. A `start_print` command is
|
||||||
|
* accepted, the file is downloaded from its presigned URL and hashed (the
|
||||||
|
* hash is appended to the frames file, never the URL), and the printer then
|
||||||
|
* reports running and finished, like a real print that goes well.
|
||||||
|
*
|
||||||
|
* Needs FARM_ID in the environment (the hello must name the token's farm).
|
||||||
|
*
|
||||||
|
* Usage: bun scripts/fake-gateway-serve.ts <ws-url> <edge-jwt> <seconds> <frames-file> [refuse-access-code]
|
||||||
|
*
|
||||||
|
* Every provision_printer / remove_printer frame it receives is appended to
|
||||||
|
* <frames-file> as one JSON line, so the script can prove the credential
|
||||||
|
* arrived here. A provision whose access_code equals <refuse-access-code> is
|
||||||
|
* answered as a failure, like a printer that rejected the login.
|
||||||
|
*/
|
||||||
|
import { appendFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const [url, jwt, seconds, framesFile, refuseCode] = process.argv.slice(2);
|
||||||
|
if (!url || !jwt || !seconds || !framesFile) {
|
||||||
|
console.error("usage: fake-gateway-serve.ts <ws-url> <edge-jwt> <seconds> <frames-file> [refuse-code]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(url, { headers: { authorization: `Bearer ${jwt}` } } as never);
|
||||||
|
ws.onerror = () => {
|
||||||
|
console.error("socket error");
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
|
ws.onopen = () => {
|
||||||
|
// farm_id is required by the hello schema; the backend checks it against the token's farm.
|
||||||
|
ws.send(JSON.stringify({ type: "hello", gateway_id: "fake-gw", farm_id: process.env.FARM_ID }));
|
||||||
|
if (process.env.REPORT_IDLE) {
|
||||||
|
ws.send(JSON.stringify({ type: "telemetry", printers: [{ printer_id: process.env.REPORT_IDLE, name: "Fake", state: "IDLE", percent: 0, print_error: 0 }] }));
|
||||||
|
}
|
||||||
|
console.log("ready");
|
||||||
|
};
|
||||||
|
|
||||||
|
async function printFile(message: { command_id: string; printer_id: string; command: { job_id: string; file_name: string; url: string } }) {
|
||||||
|
const report = (state: string, percent: number) =>
|
||||||
|
ws.send(JSON.stringify({ type: "telemetry", printers: [{ printer_id: message.printer_id, name: "Fake", state, percent, print_error: 0 }] }));
|
||||||
|
const body = new Uint8Array(await (await fetch(message.command.url)).arrayBuffer());
|
||||||
|
const digest = new Bun.CryptoHasher("sha256").update(body).digest("hex");
|
||||||
|
appendFileSync(framesFile, JSON.stringify({ type: "downloaded", file_name: message.command.file_name, bytes: body.length, sha256: digest }) + "\n");
|
||||||
|
report("RUNNING", 20);
|
||||||
|
setTimeout(() => report("RUNNING", 60), 1500);
|
||||||
|
setTimeout(() => report("FINISH", 100), 3000);
|
||||||
|
}
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const message = JSON.parse(String(event.data));
|
||||||
|
if (message.type === "command" && message.command?.action === "start_print") {
|
||||||
|
appendFileSync(framesFile, JSON.stringify({ type: "start_print", printer_id: message.printer_id, file_name: message.command.file_name, job_id: message.command.job_id }) + "\n");
|
||||||
|
ws.send(JSON.stringify({ type: "command_result", command_id: message.command_id, ok: true }));
|
||||||
|
void printFile(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type !== "provision_printer" && message.type !== "remove_printer") return;
|
||||||
|
appendFileSync(framesFile, JSON.stringify(message) + "\n");
|
||||||
|
if (message.type === "provision_printer" && process.env.TELEMETRY_AFTER_PROVISION === "1") {
|
||||||
|
const id = message.printer.id;
|
||||||
|
const report = (state: string) =>
|
||||||
|
ws.send(JSON.stringify({ type: "telemetry", printers: [{ printer_id: id, name: message.printer.name, state, percent: 40, print_error: 0 }] }));
|
||||||
|
setTimeout(() => report("RUNNING"), 1500);
|
||||||
|
setTimeout(() => report("RUNNING"), 3000);
|
||||||
|
setTimeout(() => report("FAILED"), 4500);
|
||||||
|
}
|
||||||
|
const refused = message.type === "provision_printer" && refuseCode && message.printer.access_code === refuseCode;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "command_result",
|
||||||
|
command_id: message.request_id,
|
||||||
|
ok: !refused,
|
||||||
|
...(refused ? { error: "could not connect to the printer: the access code was rejected" } : {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
setTimeout(() => process.exit(0), Number(seconds) * 1000);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* A stand-in gateway for scripts/e2e-alerts.sh: connects to /ws/edge/v1 with
|
||||||
|
* an edge JWT, says hello, then reports one printer through a sequence of
|
||||||
|
* states, one telemetry frame each, and waits for the backend to take them.
|
||||||
|
*
|
||||||
|
* Usage: bun scripts/fake-gateway.ts <ws-url> <edge-jwt> <printer-id> STATE[,STATE...]
|
||||||
|
* A state is a Bambu gcode_state word (IDLE, RUNNING, PAUSE, FAILED).
|
||||||
|
*/
|
||||||
|
const [url, jwt, printerId, states] = process.argv.slice(2);
|
||||||
|
if (!url || !jwt || !printerId || !states) {
|
||||||
|
console.error("usage: fake-gateway.ts <ws-url> <edge-jwt> <printer-id> STATE[,STATE...]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(url, { headers: { authorization: `Bearer ${jwt}` } } as never);
|
||||||
|
ws.onerror = () => {
|
||||||
|
console.error("socket error");
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
|
ws.onopen = async () => {
|
||||||
|
ws.send(JSON.stringify({ type: "hello", gateway_id: "fake-gw" }));
|
||||||
|
for (const state of states.split(",")) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "telemetry",
|
||||||
|
printers: [{ printer_id: printerId, name: "Fake", state, percent: 10, print_error: 0 }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Telemetry is persisted in order per socket; a pause keeps the frames
|
||||||
|
// from being coalesced into one observation.
|
||||||
|
await Bun.sleep(400);
|
||||||
|
}
|
||||||
|
await Bun.sleep(500);
|
||||||
|
ws.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// A stand-in for the REST printers (Klipper's Moonraker, OctoPrint, PrusaLink)
|
||||||
|
// so the real proxy can be driven end to end without owning one. It is only as
|
||||||
|
// good as my reading of those APIs - it proves the plumbing, not the hardware.
|
||||||
|
//
|
||||||
|
// bun scripts/fake-http-printer.ts <klipper|octoprint|prusa_link> <port> [apiKey]
|
||||||
|
//
|
||||||
|
// A file uploaded with "print" set starts a fake print that runs for RUN_S
|
||||||
|
// seconds (default 8) and then finishes. Every request is logged to stdout.
|
||||||
|
const [kind, portArg, apiKey = "e2e-key"] = process.argv.slice(2);
|
||||||
|
if (!["klipper", "octoprint", "prusa_link"].includes(kind ?? "")) {
|
||||||
|
console.error("usage: fake-http-printer.ts <klipper|octoprint|prusa_link> <port> [apiKey]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const RUN_S = Number(process.env.RUN_S ?? 8);
|
||||||
|
|
||||||
|
let file: string | undefined;
|
||||||
|
let startedAt: number | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const progress = () => (startedAt === undefined ? 0 : cancelled ? 0 : Math.min(1, (Date.now() - startedAt) / (RUN_S * 1000)));
|
||||||
|
const phase = () => (startedAt === undefined ? "idle" : cancelled ? "cancelled" : progress() >= 1 ? "done" : "printing");
|
||||||
|
const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||||
|
|
||||||
|
function startFrom(body: string, name?: string) {
|
||||||
|
file = name ?? /filename="([^"]+)"/.exec(body)?.[1] ?? "unknown";
|
||||||
|
startedAt = Date.now();
|
||||||
|
cancelled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
port: Number(portArg),
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const body = req.method === "GET" ? "" : new TextDecoder().decode(await req.arrayBuffer());
|
||||||
|
console.log(`${req.method} ${url.pathname} ${body.length}b`);
|
||||||
|
const route = `${req.method} ${url.pathname}`;
|
||||||
|
|
||||||
|
if (kind === "klipper") {
|
||||||
|
if (route === "GET /printer/info") return json({ result: { state: "ready" } });
|
||||||
|
if (route === "GET /printer/objects/query") {
|
||||||
|
const p = progress();
|
||||||
|
const state = { idle: "standby", printing: "printing", done: "complete", cancelled: "cancelled" }[phase()];
|
||||||
|
return json({
|
||||||
|
result: {
|
||||||
|
status: {
|
||||||
|
print_stats: { state, filename: file ?? "", print_duration: p * RUN_S, info: { current_layer: Math.ceil(p * 10), total_layer: 10 } },
|
||||||
|
virtual_sdcard: { progress: p },
|
||||||
|
extruder: { temperature: phase() === "printing" ? 210 : 24, target: phase() === "printing" ? 210 : 0 },
|
||||||
|
heater_bed: { temperature: phase() === "printing" ? 60 : 23, target: phase() === "printing" ? 60 : 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (route === "POST /server/files/upload") {
|
||||||
|
if (!/name="print"\r\n\r\ntrue/.test(body)) return json({ error: "print flag missing" }, 400);
|
||||||
|
startFrom(body);
|
||||||
|
return json({ print_started: true }, 201);
|
||||||
|
}
|
||||||
|
if (route === "POST /printer/print/cancel") { cancelled = true; return json({ result: "ok" }); }
|
||||||
|
if (route === "POST /printer/gcode/script") return json({ result: "ok" });
|
||||||
|
} else {
|
||||||
|
if (req.headers.get("x-api-key") !== apiKey) return json({ error: "bad key" }, 403);
|
||||||
|
const ph = phase();
|
||||||
|
if (kind === "octoprint") {
|
||||||
|
if (route === "GET /api/job") {
|
||||||
|
return json({
|
||||||
|
state: ph === "printing" ? "Printing" : "Operational",
|
||||||
|
job: { file: { name: file ?? null, display: file ?? null } },
|
||||||
|
progress: { completion: ph === "idle" || ph === "cancelled" ? null : progress() * 100, printTimeLeft: Math.round((1 - progress()) * RUN_S) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (route === "GET /api/printer") {
|
||||||
|
return json({ temperature: { tool0: { actual: ph === "printing" ? 210 : 24, target: ph === "printing" ? 210 : 0 }, bed: { actual: 60, target: ph === "printing" ? 60 : 0 } } });
|
||||||
|
}
|
||||||
|
if (route === "POST /api/files/local") {
|
||||||
|
if (!/name="print"\r\n\r\ntrue/.test(body)) return json({ error: "print flag missing" }, 400);
|
||||||
|
startFrom(body);
|
||||||
|
return json({ done: true }, 201);
|
||||||
|
}
|
||||||
|
if (route === "POST /api/job") { if (body.includes("cancel")) cancelled = true; return new Response(null, { status: 204 }); }
|
||||||
|
if (route === "POST /api/printer/command") return new Response(null, { status: 204 });
|
||||||
|
} else {
|
||||||
|
const state = { idle: "IDLE", printing: "PRINTING", done: "FINISHED", cancelled: "STOPPED" }[ph];
|
||||||
|
if (route === "GET /api/v1/status") {
|
||||||
|
return json({
|
||||||
|
printer: { state, temp_nozzle: ph === "printing" ? 215 : 25, target_nozzle: ph === "printing" ? 215 : 0, temp_bed: 60, target_bed: 0 },
|
||||||
|
...(ph === "printing" ? { job: { id: 1, progress: progress() * 100, time_remaining: Math.round((1 - progress()) * RUN_S) } } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (route === "GET /api/v1/job") return json({ id: 1, file: { display_name: file } });
|
||||||
|
if (req.method === "PUT" && url.pathname.startsWith("/api/v1/files/local/")) {
|
||||||
|
if (req.headers.get("print-after-upload") !== "?1") return json({ error: "print flag missing" }, 400);
|
||||||
|
startFrom(body, decodeURIComponent(url.pathname.split("/").pop() ?? ""));
|
||||||
|
return new Response(null, { status: 201 });
|
||||||
|
}
|
||||||
|
if (route === "DELETE /api/v1/job/1") { cancelled = true; return new Response(null, { status: 204 }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json({ error: `no route ${route}` }, 404);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`fake ${kind} listening on :${portArg}`);
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# THE FIRST REAL DISPATCH: queue a job in a throwaway farm that holds only the
|
||||||
|
# P1P, let the real scheduler send it through the real proxy, and watch the
|
||||||
|
# machine print it.
|
||||||
|
#
|
||||||
|
# **This starts a physical print.** It refuses to run without
|
||||||
|
# I_AUTHORISE_A_REAL_PRINT=yes, and only ever targets PRINTER_ID's printer
|
||||||
|
# (default p1p) because the throwaway farm contains nothing else. The file is
|
||||||
|
# read from the printer's own SD card root (FILE, default
|
||||||
|
# auto-test-001.gcode.3mf), uploaded to the object store, and dispatched like
|
||||||
|
# any customer job; the proxy then downloads it, FTPS-uploads it to the card
|
||||||
|
# and starts it, mapping filament from the archive itself.
|
||||||
|
#
|
||||||
|
# Nothing here cancels the print. To stop it: the printer's own screen, or
|
||||||
|
# the console's Stop control.
|
||||||
|
set -euo pipefail
|
||||||
|
[ "${I_AUTHORISE_A_REAL_PRINT:-}" = "yes" ] || { echo "refusing: set I_AUTHORISE_A_REAL_PRINT=yes to start a real print" >&2; exit 2; }
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
PRINTER_ID="${PRINTER_ID:-p1p}"
|
||||||
|
FILE="${FILE:-auto-test-001.gcode.3mf}"
|
||||||
|
# 0-based across all AMS units (AMS1 slot 4 is 3). Unset: map by the project's filament.
|
||||||
|
AMS_SLOT="${AMS_SLOT:-}"
|
||||||
|
PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}"
|
||||||
|
GO2RTC_BIN="${GO2RTC_BIN:-$HOME/.local/bin/go2rtc}"
|
||||||
|
PORT="${PORT:-3060}"; AUTH_PORT="${AUTH_PORT:-3061}"
|
||||||
|
BASE="http://localhost:$PORT"
|
||||||
|
DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}"
|
||||||
|
VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}"
|
||||||
|
EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}"
|
||||||
|
export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT
|
||||||
|
MAX_WAIT_S="${MAX_WAIT_S:-5400}"
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
BACKEND_PID=""; PROXY_PID=""; AUTH_PID=""; OBJECT_KEY=""
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
ok() { echo " ok: $*"; }
|
||||||
|
psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; }
|
||||||
|
USER_ID="$(cat /proc/sys/kernel/random/uuid)"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local rc=$?
|
||||||
|
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done
|
||||||
|
for _ in $(seq 1 20); do [ -n "$PROXY_PID" ] && kill -0 "$PROXY_PID" 2>/dev/null || break; sleep 0.3; done
|
||||||
|
for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done
|
||||||
|
[ -n "$OBJECT_KEY" ] && bun -e "
|
||||||
|
import { s3 } from './src/s3';
|
||||||
|
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
await s3.send(new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET ?? 'continuum', Key: process.argv[1] }));
|
||||||
|
" "$OBJECT_KEY" >/dev/null 2>&1 || true
|
||||||
|
[ -z "${KEEP_DATA:-}" ] && [ -n "${ORG_ID:-}" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true
|
||||||
|
if [ $rc -ne 0 ]; then
|
||||||
|
echo "--- backend log (tail) ---"; tail -25 "$WORK/backend.log" 2>/dev/null || true
|
||||||
|
echo "--- proxy log (tail) ---"; tail -40 "$WORK/proxy.log" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -rf "$WORK"
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
[ -f "$PROXY_DIR/printers.toml" ] || fail "no printers.toml"
|
||||||
|
grep -q "^id *= *\"$PRINTER_ID\"" "$PROXY_DIR/printers.toml" || fail "printers.toml has no printer '$PRINTER_ID'"
|
||||||
|
ps -eo comm | grep -qx go2rtc && fail "a go2rtc is already running"
|
||||||
|
|
||||||
|
echo "==> reading $FILE from $PRINTER_ID's SD card (read-only)"
|
||||||
|
( cd "$PROXY_DIR" && cargo run -q --example bambu_mapping_check -- "$PRINTER_ID" "$FILE" "$WORK/job.3mf" ) > "$WORK/mapping.txt" 2>&1 || { cat "$WORK/mapping.txt"; fail "could not read the file"; }
|
||||||
|
grep -E 'gcode_state|project filament|mapping' "$WORK/mapping.txt" | sed 's/^/ /'
|
||||||
|
grep -q 'gcode_state: Some("IDLE")' "$WORK/mapping.txt" || grep -q 'gcode_state: Some("FINISH")' "$WORK/mapping.txt" || fail "the printer is not idle"
|
||||||
|
grep -q 'mapping the production path would use' "$WORK/mapping.txt" || fail "the filament would not map; not starting"
|
||||||
|
WANT="$(sha256sum "$WORK/job.3mf" | cut -d' ' -f1)"
|
||||||
|
|
||||||
|
echo "==> seeding a throwaway farm holding only $PRINTER_ID"
|
||||||
|
EDGE_PROXY_TOKEN="real-$(date +%s)"
|
||||||
|
ORG_ID="$(psql_q "INSERT INTO organisations (name) VALUES ('real print org') RETURNING id")"
|
||||||
|
psql_q "INSERT INTO org_members (org_id, user_id, role) VALUES ('$ORG_ID', '$USER_ID', 'owner')" >/dev/null
|
||||||
|
FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token) VALUES ('real print farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")"
|
||||||
|
PRN_NAME="$(awk -F'"' -v id="$PRINTER_ID" '/^id *=/ {cur=$2} cur==id && /^name *=/ {print $2; exit}' "$PROXY_DIR/printers.toml")"
|
||||||
|
psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id) VALUES ('$FARM_ID', '${PRN_NAME:-P1P}', 'bambu', '$PRINTER_ID')" >/dev/null
|
||||||
|
PRN_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID'")"
|
||||||
|
[ "$(psql_q "SELECT count(*) FROM printers WHERE farm_id='$FARM_ID'")" = 1 ] || fail "the farm must hold exactly one printer"
|
||||||
|
|
||||||
|
bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 &
|
||||||
|
AUTH_PID=$!
|
||||||
|
AUTH_URL="http://localhost:$AUTH_PORT"
|
||||||
|
for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done
|
||||||
|
export SUPABASE_URL="$AUTH_URL"
|
||||||
|
TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')"
|
||||||
|
echo "==> starting the backend WITH dispatch on"
|
||||||
|
DISPATCH_ENABLED=true bun run src/index.ts > "$WORK/backend.log" 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && break; sleep 0.5; done
|
||||||
|
curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy"
|
||||||
|
|
||||||
|
echo "==> starting the real proxy"
|
||||||
|
env -C "$PROXY_DIR" \
|
||||||
|
CONTINUUM_ALLOW_INSECURE_LOCAL_DOWNLOADS=true CONTINUUM_GATEWAY_ID=real-print-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \
|
||||||
|
CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \
|
||||||
|
CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
|
||||||
|
CONTINUUM_PRINTERS_PATH=printers.toml CONTINUUM_STORE_PATH="$WORK/continuum.db" RUST_LOG=info \
|
||||||
|
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
|
||||||
|
PROXY_PID=$!
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = "idle" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
[ "$(psql_q "SELECT status FROM printers WHERE id='$PRN_UUID'")" = "idle" ] || fail "the cloud never saw $PRINTER_ID idle"
|
||||||
|
ok "the cloud sees $PRINTER_ID idle"
|
||||||
|
|
||||||
|
echo "==> queueing the job"
|
||||||
|
J="$(curl -sf -X POST "$BASE/jobs" -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
|
||||||
|
-d "{\"farmId\":\"$FARM_ID\",\"fileName\":\"$FILE\",\"contentType\":\"application/octet-stream\"${AMS_SLOT:+,\"amsSlot\":$AMS_SLOT}}")"
|
||||||
|
JOB_ID="$(printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["job"]["id"])')"
|
||||||
|
OBJECT_KEY="$(printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["job"]["r2ObjectKey"])')"
|
||||||
|
UPLOAD_URL="$(printf '%s' "$J" | python3 -c 'import json,sys; print(json.load(sys.stdin)["uploadUrl"])')"
|
||||||
|
# The job is queued the moment it exists; dispatch needs the bytes, so the
|
||||||
|
# window between the two is a race the scheduler can only lose (the proxy's
|
||||||
|
# download then fails and the job fails), which is why this is done at once.
|
||||||
|
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/octet-stream' --data-binary @"$WORK/job.3mf" "$UPLOAD_URL")"
|
||||||
|
[ "$CODE" = 200 ] || fail "upload returned HTTP $CODE"
|
||||||
|
ok "job $JOB_ID queued, $(stat -c %s "$WORK/job.3mf") bytes uploaded"
|
||||||
|
|
||||||
|
echo "==> watching (up to ${MAX_WAIT_S}s)"
|
||||||
|
SEEN=""; LAST=""; START=$SECONDS
|
||||||
|
while [ $((SECONDS - START)) -lt "$MAX_WAIT_S" ]; do
|
||||||
|
STATUS="$(psql_q "SELECT status FROM print_jobs WHERE id='$JOB_ID'")"
|
||||||
|
PRN="$(psql_q "SELECT coalesce(vendor_state,'-') || ' ' || coalesce(progress_percent::text,'-') || '% layer ' || coalesce(current_layer::text,'-') || '/' || coalesce(total_layers::text,'-') || ' nozzle ' || coalesce(nozzle_temp_c::text,'-') FROM printers WHERE id='$PRN_UUID'")"
|
||||||
|
NOW="job=$STATUS printer=$PRN"
|
||||||
|
[ "$NOW" != "$LAST" ] && { echo " [$((SECONDS - START))s] $NOW"; LAST="$NOW"; }
|
||||||
|
case " $SEEN " in *" $STATUS "*) ;; *) SEEN="$SEEN $STATUS" ;; esac
|
||||||
|
[ "$STATUS" = completed ] && break
|
||||||
|
[ "$STATUS" = failed ] && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo "job status history:$SEEN"
|
||||||
|
if [ "$STATUS" = completed ]; then
|
||||||
|
grep -E 'uploaded a project file|mapped the project|start' "$WORK/proxy.log" | sed 's/^/ proxy: /' | head -8
|
||||||
|
echo "PASS - the first real dispatch: queued in the cloud, scheduled, uploaded to the P1P, printed and completed."
|
||||||
|
else
|
||||||
|
fail "the job ended as '$STATUS'"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Which telemetry changes deserve an operator's attention, on their own with
|
||||||
|
* no imports - same reason as `dispatch/job_status.ts`: `../db` opens a
|
||||||
|
* connection at import time, and this is the decision worth testing without
|
||||||
|
* one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AlertKind = "print_failed" | "print_paused" | "printer_error";
|
||||||
|
export type Status = "offline" | "idle" | "printing" | "paused" | "error" | "maintenance";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What changed, if anything, that is worth raising.
|
||||||
|
*
|
||||||
|
* Transitions and not levels: a printer that sits in `error` for an hour is
|
||||||
|
* one alert, not 720. `previous` undefined means this connection has not seen
|
||||||
|
* the printer before, which raises nothing - a gateway reconnecting would
|
||||||
|
* otherwise re-announce every fault that was already known.
|
||||||
|
*
|
||||||
|
* A fault while printing is a failed print; a fault while idle is the printer
|
||||||
|
* needing attention, which is a different thing to tell someone.
|
||||||
|
*/
|
||||||
|
export function deriveAlert(
|
||||||
|
previous: Status | undefined,
|
||||||
|
next: Status | undefined,
|
||||||
|
printerName: string,
|
||||||
|
vendorState?: string | null,
|
||||||
|
): { kind: AlertKind; message: string } | undefined {
|
||||||
|
if (previous === undefined || next === undefined || previous === next) return undefined;
|
||||||
|
const detail = vendorState ? ` (printer reports ${vendorState})` : "";
|
||||||
|
|
||||||
|
if (next === "error") {
|
||||||
|
return previous === "printing" || previous === "paused"
|
||||||
|
? { kind: "print_failed", message: `${printerName}: the print failed${detail}` }
|
||||||
|
: { kind: "printer_error", message: `${printerName} reported an error${detail}` };
|
||||||
|
}
|
||||||
|
// Paused by the printer itself is how an AMS fault shows up - the print is
|
||||||
|
// still on the bed and nobody is going to notice without being told. A
|
||||||
|
// pause the operator asked for is also announced; telling them what they
|
||||||
|
// just did is cheap, and the console cannot tell the two apart.
|
||||||
|
if (next === "paused" && previous === "printing") {
|
||||||
|
return { kind: "print_paused", message: `${printerName}: the print is paused${detail}` };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an AI detection is worth a row; the worker deliberately does not threshold. */
|
||||||
|
export function worthRaising(confidence: number, minimum: number): boolean {
|
||||||
|
return confidence >= minimum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The floor for AI detections, overridable because it is a taste, not a fact. */
|
||||||
|
export function minAiConfidence(env: Record<string, string | undefined>): number {
|
||||||
|
const parsed = Number(env.ALERT_MIN_CONFIDENCE);
|
||||||
|
return env.ALERT_MIN_CONFIDENCE !== undefined && parsed >= 0 && parsed <= 1 ? parsed : 0.6;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { printerAlerts, printers } from "../db/schema";
|
||||||
|
import { valkey } from "../queue";
|
||||||
|
import { minAiConfidence, worthRaising } from "./derive";
|
||||||
|
import { parsePrintAlert } from "./parse";
|
||||||
|
|
||||||
|
const CHANNEL = "channel:print_alerts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the AI worker's fire-and-forget publications into rows.
|
||||||
|
*
|
||||||
|
* PUBLISH keeps nothing, so a detection published while this process is down
|
||||||
|
* is gone - the same limit the schema documents. Everything that arrives
|
||||||
|
* while it is up is kept, which is the difference between an alert and a
|
||||||
|
* flicker.
|
||||||
|
*
|
||||||
|
* A subscribed connection can do nothing else in RESP, hence `duplicate()`
|
||||||
|
* rather than reusing the shared client.
|
||||||
|
*/
|
||||||
|
export function startAiAlertListener(): void {
|
||||||
|
const floor = minAiConfidence(process.env);
|
||||||
|
const subscriber = valkey.duplicate();
|
||||||
|
subscriber.on("error", (err) => console.warn("[alerts] listener connection", err.message));
|
||||||
|
|
||||||
|
subscriber.subscribe(CHANNEL).catch((err: unknown) => console.warn("[alerts] cannot subscribe", err));
|
||||||
|
subscriber.on("message", async (_channel, raw) => {
|
||||||
|
const alert = parsePrintAlert(raw);
|
||||||
|
if (!alert || !worthRaising(alert.confidence, floor)) return;
|
||||||
|
try {
|
||||||
|
const printer = await db.query.printers.findFirst({
|
||||||
|
columns: { id: true, farmId: true, name: true },
|
||||||
|
where: eq(printers.id, alert.printer_id),
|
||||||
|
});
|
||||||
|
// An alert for a printer we do not have has no farm to show it to.
|
||||||
|
if (!printer) return;
|
||||||
|
await db.insert(printerAlerts).values({
|
||||||
|
farmId: printer.farmId,
|
||||||
|
printerId: printer.id,
|
||||||
|
kind: "ai_detection",
|
||||||
|
message: `${printer.name}: looks like ${alert.label} (${Math.round(alert.confidence * 100)}%)`,
|
||||||
|
confidence: alert.confidence,
|
||||||
|
objectKey: alert.r2_object_key,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[alerts] could not record an AI alert", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
||||||
|
import { PrintAlertSchema, type PrintAlert } from "@continuum/ts-types";
|
||||||
|
// Must come first - see queue/frame_task.ts: an unregistered format fails.
|
||||||
|
import "../validation/formats";
|
||||||
|
|
||||||
|
const check = TypeCompiler.Compile(PrintAlertSchema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a `channel:print_alerts` message, or returns undefined for anything
|
||||||
|
* that is not a valid `PrintAlert`. A subscriber must never throw on what
|
||||||
|
* arrives: the channel is shared and a bad publisher should cost one message,
|
||||||
|
* not the listener.
|
||||||
|
*/
|
||||||
|
export function parsePrintAlert(raw: string): PrintAlert | undefined {
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return check.Check(value) ? (value as PrintAlert) : undefined;
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ export { requireUser, type AuthenticatedUser } from "./plugin";
|
|||||||
export { roleSatisfies, ORG_ROLES, type OrgRole } from "./roles";
|
export { roleSatisfies, ORG_ROLES, type OrgRole } from "./roles";
|
||||||
export {
|
export {
|
||||||
listMemberships,
|
listMemberships,
|
||||||
|
requireAlert,
|
||||||
requireFarm,
|
requireFarm,
|
||||||
requireJob,
|
requireJob,
|
||||||
requireOrg,
|
requireOrg,
|
||||||
requirePrinter,
|
requirePrinter,
|
||||||
|
visibleAlerts,
|
||||||
visibleFarms,
|
visibleFarms,
|
||||||
visibleJobs,
|
visibleJobs,
|
||||||
visiblePrinters,
|
visiblePrinters,
|
||||||
|
|||||||
+31
-1
@@ -1,7 +1,7 @@
|
|||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { ForbiddenError, NotFoundError } from "@continuum/ts-core";
|
import { ForbiddenError, NotFoundError } from "@continuum/ts-core";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { farms, orgMembers, printJobs, printers } from "../db/schema";
|
import { farms, orgMembers, printJobs, printerAlerts, printers } from "../db/schema";
|
||||||
import { roleSatisfies, type OrgRole } from "./roles";
|
import { roleSatisfies, type OrgRole } from "./roles";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +64,14 @@ export function visibleJobs(userId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `where` condition limiting `printer_alerts` to those the user can see. */
|
||||||
|
export function visibleAlerts(userId: string) {
|
||||||
|
return inArray(
|
||||||
|
printerAlerts.farmId,
|
||||||
|
db.select({ id: farms.id }).from(farms).where(visibleFarms(userId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function check(role: OrgRole | null, minimum: OrgRole, missing: string, action: string): OrgRole {
|
function check(role: OrgRole | null, minimum: OrgRole, missing: string, action: string): OrgRole {
|
||||||
if (role === null) throw new NotFoundError(missing);
|
if (role === null) throw new NotFoundError(missing);
|
||||||
if (!roleSatisfies(role, minimum)) {
|
if (!roleSatisfies(role, minimum)) {
|
||||||
@@ -156,3 +164,25 @@ export async function requireJob(
|
|||||||
const role = check(row?.role ?? null, minimum, `Print job ${jobId} not found`, action);
|
const role = check(row?.role ?? null, minimum, `Print job ${jobId} not found`, action);
|
||||||
return { farmId: row!.farmId, orgId: row!.orgId, role };
|
return { farmId: row!.farmId, orgId: row!.orgId, role };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** As `requireFarm`, resolved through the alert's farm. */
|
||||||
|
export async function requireAlert(
|
||||||
|
userId: string,
|
||||||
|
alertId: string,
|
||||||
|
minimum: OrgRole,
|
||||||
|
action = "this operation",
|
||||||
|
): Promise<{ farmId: string; orgId: string; role: OrgRole }> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ farmId: printerAlerts.farmId, orgId: farms.orgId, role: orgMembers.role })
|
||||||
|
.from(printerAlerts)
|
||||||
|
.innerJoin(farms, eq(farms.id, printerAlerts.farmId))
|
||||||
|
.leftJoin(
|
||||||
|
orgMembers,
|
||||||
|
and(eq(orgMembers.orgId, farms.orgId), eq(orgMembers.userId, userId)),
|
||||||
|
)
|
||||||
|
.where(eq(printerAlerts.id, alertId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const role = check(row?.role ?? null, minimum, `Alert ${alertId} not found`, action);
|
||||||
|
return { farmId: row!.farmId, orgId: row!.orgId, role };
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const printerProjection = projection(printers, [
|
|||||||
// `GET /printers/:id/stream` per printer, and in a mixed fleet most of
|
// `GET /printers/:id/stream` per printer, and in a mixed fleet most of
|
||||||
// those 404 by design.
|
// those 404 by design.
|
||||||
"cameraStream",
|
"cameraStream",
|
||||||
|
"cameraSnapshot",
|
||||||
"vendorState",
|
"vendorState",
|
||||||
"vendorErrorCode",
|
"vendorErrorCode",
|
||||||
"lastSeenAt",
|
"lastSeenAt",
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { pgTable, uuid, text, timestamp, real, pgEnum, index } from "drizzle-orm/pg-core";
|
||||||
|
import { farms } from "./farms";
|
||||||
|
import { printers } from "./printers";
|
||||||
|
|
||||||
|
export const alertKindEnum = pgEnum("alert_kind", [
|
||||||
|
"print_failed",
|
||||||
|
"print_paused",
|
||||||
|
"printer_error",
|
||||||
|
"ai_detection",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The permanent record of something an operator should look at.
|
||||||
|
*
|
||||||
|
* A table and not a channel: Valkey PUBLISH keeps nothing, so an alert raised
|
||||||
|
* while no console was open would otherwise be gone for good. The console
|
||||||
|
* lists open (unacknowledged) rows, which is also what makes "I was away,
|
||||||
|
* what happened" answerable.
|
||||||
|
*/
|
||||||
|
export const printerAlerts = pgTable(
|
||||||
|
"printer_alerts",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
farmId: uuid("farm_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => farms.id, { onDelete: "cascade" }),
|
||||||
|
// Set null rather than cascade: the history of why a deleted printer was
|
||||||
|
// flagged is still worth having, and the farm cascade bounds it anyway.
|
||||||
|
printerId: uuid("printer_id").references(() => printers.id, { onDelete: "set null" }),
|
||||||
|
kind: alertKindEnum("kind").notNull(),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
// Only for ai_detection; null is "not applicable", not zero confidence.
|
||||||
|
confidence: real("confidence"),
|
||||||
|
// Where the frame the detection came from lives, for ai_detection.
|
||||||
|
objectKey: text("object_key"),
|
||||||
|
acknowledgedBy: uuid("acknowledged_by"),
|
||||||
|
acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
farmOpen: index("printer_alerts_farm_open_idx").on(table.farmId, table.acknowledgedAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PrinterAlert = typeof printerAlerts.$inferSelect;
|
||||||
@@ -3,3 +3,5 @@ export * from "./farms";
|
|||||||
export * from "./printers";
|
export * from "./printers";
|
||||||
export * from "./print_jobs";
|
export * from "./print_jobs";
|
||||||
export * from "./embeddings";
|
export * from "./embeddings";
|
||||||
|
export * from "./alerts";
|
||||||
|
export * from "./onboarding";
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
|
||||||
|
import { orgRoleEnum, organisations } from "./orgs";
|
||||||
|
import { farms } from "./farms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An invitation to join an organisation, addressed to an email.
|
||||||
|
*
|
||||||
|
* Only a hash of the token is stored: the token is the whole credential, and
|
||||||
|
* the table is the kind of thing that ends up in a backup or a screenshot.
|
||||||
|
* It is shown to the inviter once, when the invitation is made.
|
||||||
|
*/
|
||||||
|
export const orgInvitations = pgTable(
|
||||||
|
"org_invitations",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
orgId: uuid("org_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => organisations.id, { onDelete: "cascade" }),
|
||||||
|
/** Lower-cased: addresses are compared case-insensitively. */
|
||||||
|
email: text("email").notNull(),
|
||||||
|
role: orgRoleEnum("role").notNull(),
|
||||||
|
tokenHash: text("token_hash").notNull().unique(),
|
||||||
|
invitedBy: uuid("invited_by").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
|
||||||
|
acceptedBy: uuid("accepted_by"),
|
||||||
|
},
|
||||||
|
(table) => ({ orgIdx: index("org_invitations_org_idx").on(table.orgId) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short-lived, single-use code that lets a gateway fetch its farm's
|
||||||
|
* credentials without anyone copying a token around. Hashed like an
|
||||||
|
* invitation token, and for the same reason.
|
||||||
|
*/
|
||||||
|
export const enrolmentCodes = pgTable(
|
||||||
|
"enrolment_codes",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
farmId: uuid("farm_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => farms.id, { onDelete: "cascade" }),
|
||||||
|
codeHash: text("code_hash").notNull().unique(),
|
||||||
|
createdBy: uuid("created_by").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => ({ farmIdx: index("enrolment_codes_farm_idx").on(table.farmId) }),
|
||||||
|
);
|
||||||
@@ -47,6 +47,12 @@ export const orgMembers = pgTable(
|
|||||||
.references(() => organisations.id, { onDelete: "cascade" }),
|
.references(() => organisations.id, { onDelete: "cascade" }),
|
||||||
userId: uuid("user_id").notNull(),
|
userId: uuid("user_id").notNull(),
|
||||||
role: orgRoleEnum("role").notNull().default("viewer"),
|
role: orgRoleEnum("role").notNull().default("viewer"),
|
||||||
|
/**
|
||||||
|
* Shown in member lists, which would otherwise be bare ids. Taken from
|
||||||
|
* the token's `email` claim when someone creates an organisation or
|
||||||
|
* accepts an invitation; null for a member added by id.
|
||||||
|
*/
|
||||||
|
email: text("email"),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real, integer, uniqueIndex } from "drizzle-orm/pg-core";
|
import { pgTable, uuid, text, timestamp, jsonb, pgEnum, real, integer, boolean, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
import { farms } from "./farms";
|
import { farms } from "./farms";
|
||||||
|
|
||||||
export const printerVendorEnum = pgEnum("printer_vendor", ["bambu", "prusa", "klipper"]);
|
export const printerVendorEnum = pgEnum("printer_vendor", ["bambu", "prusa", "klipper", "octoprint"]);
|
||||||
export const printerStatusEnum = pgEnum("printer_status", [
|
export const printerStatusEnum = pgEnum("printer_status", [
|
||||||
"offline",
|
"offline",
|
||||||
"idle",
|
"idle",
|
||||||
@@ -70,6 +70,11 @@ export const printers = pgTable(
|
|||||||
* restream it.
|
* restream it.
|
||||||
*/
|
*/
|
||||||
cameraStream: text("camera_stream"),
|
cameraStream: text("camera_stream"),
|
||||||
|
/**
|
||||||
|
* The camera can only be read as single JPEG frames (a P1/A1), which the
|
||||||
|
* gateway serves on request. Mutually exclusive with `cameraStream`.
|
||||||
|
*/
|
||||||
|
cameraSnapshot: boolean("camera_snapshot").notNull().default(false),
|
||||||
/**
|
/**
|
||||||
* The printer's own state string, kept alongside the narrowed
|
* The printer's own state string, kept alongside the narrowed
|
||||||
* `status` enum. Worth storing both: the enum is what the UI filters
|
* `status` enum. Worth storing both: the enum is what the UI filters
|
||||||
|
|||||||
+87
-3
@@ -1,9 +1,10 @@
|
|||||||
import { and, asc, eq, inArray, isNotNull, isNull, lt } from "drizzle-orm";
|
import { and, asc, eq, inArray, isNotNull, isNull, lt, sql } from "drizzle-orm";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { printers, printJobs } from "../db/schema";
|
import { printers, printJobs } from "../db/schema";
|
||||||
import { getDownloadUrl, headObject } from "../s3";
|
import { getDownloadUrl, headObject } from "../s3";
|
||||||
import { isGatewayOnline, sendPrinterCommand } from "../routes/ws/edge";
|
import { isGatewayOnline, sendPrinterCommand } from "../routes/ws/edge";
|
||||||
import { nextJobStatus } from "./job_status";
|
import { jobStatusFromGatewayEvent, nextJobStatus } from "./job_status";
|
||||||
|
import { rememberStart, takeStart } from "./late_failure";
|
||||||
import { dispatchEnabled, dispatchSwitchWarning } from "./enabled";
|
import { dispatchEnabled, dispatchSwitchWarning } from "./enabled";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,8 +214,13 @@ export async function dispatchTick(): Promise<number> {
|
|||||||
job_id: job.id,
|
job_id: job.id,
|
||||||
file_name: job.fileName,
|
file_name: job.fileName,
|
||||||
url,
|
url,
|
||||||
|
// Chosen by whoever queued the job; absent means "by the project's filament".
|
||||||
|
...(typeof job.metadata?.amsSlot === "number" ? { ams_slot: job.metadata.amsSlot } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Accepted or not, a late result for this command can name the job.
|
||||||
|
rememberStart(result.commandId, job.id);
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
// Requeued, not failed. The gateway refusing a start is usually
|
// Requeued, not failed. The gateway refusing a start is usually
|
||||||
// transient from the cloud's point of view - the printer was busier
|
// transient from the cloud's point of view - the printer was busier
|
||||||
@@ -247,6 +253,84 @@ export async function dispatchTick(): Promise<number> {
|
|||||||
return dispatched;
|
return dispatched;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A gateway that had accepted a `start_print` reports that the download or
|
||||||
|
* transfer then failed. Fails the job with that reason - but only while it is
|
||||||
|
* still `uploading`: one that has since reached `printing` is being driven by
|
||||||
|
* the printer's own telemetry, and a late message must not overrule it.
|
||||||
|
*
|
||||||
|
* Failed rather than requeued, for the reason `reapStalledUploads` gives.
|
||||||
|
*/
|
||||||
|
export async function failJobFromGateway(commandId: string, error: string): Promise<void> {
|
||||||
|
const jobId = takeStart(commandId);
|
||||||
|
if (!jobId) return;
|
||||||
|
|
||||||
|
const failed = await db
|
||||||
|
.update(printJobs)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
metadata: sql`coalesce(${printJobs.metadata}, '{}'::jsonb) || ${JSON.stringify({
|
||||||
|
lastDispatchError: `the gateway could not start it: ${error}`,
|
||||||
|
lastDispatchErrorAt: new Date().toISOString(),
|
||||||
|
})}::jsonb`,
|
||||||
|
})
|
||||||
|
.where(and(eq(printJobs.id, jobId), eq(printJobs.status, "uploading")))
|
||||||
|
.returning({ id: printJobs.id });
|
||||||
|
|
||||||
|
if (failed.length > 0) console.warn(`[dispatch] job ${jobId} failed: the gateway could not start it: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies an outcome a gateway kept for us. Throws only when the database
|
||||||
|
* could not be reached - the one case where the gateway must NOT be told it
|
||||||
|
* was handled, so that it sends it again. A job that is unknown, belongs to
|
||||||
|
* another farm, or is no longer in flight is not an error: there is nothing
|
||||||
|
* to change, and the gateway should stop sending it.
|
||||||
|
*/
|
||||||
|
export async function applyGatewayJobEvent(
|
||||||
|
farmId: string,
|
||||||
|
event: { job_id: string; event: "started" | "finished" | "failed"; detail?: string | null; at_ms: number },
|
||||||
|
): Promise<"applied" | "ignored"> {
|
||||||
|
// The farm in the `where` is the authorisation: a gateway can only move
|
||||||
|
// jobs of the farm it is connected for.
|
||||||
|
const job = await db.query.printJobs.findFirst({
|
||||||
|
columns: { id: true, status: true },
|
||||||
|
where: and(eq(printJobs.id, event.job_id), eq(printJobs.farmId, farmId)),
|
||||||
|
});
|
||||||
|
if (!job) return "ignored";
|
||||||
|
|
||||||
|
const next = jobStatusFromGatewayEvent(job.status, event.event);
|
||||||
|
if (!next) return "ignored";
|
||||||
|
|
||||||
|
const at = new Date(event.at_ms);
|
||||||
|
const terminal = next === "completed" || next === "failed";
|
||||||
|
const updated = await db
|
||||||
|
.update(printJobs)
|
||||||
|
.set({
|
||||||
|
status: next,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...(next === "completed" ? { progressPct: 100 } : {}),
|
||||||
|
...(terminal ? { completedAt: at } : {}),
|
||||||
|
...(next === "failed"
|
||||||
|
? {
|
||||||
|
metadata: sql`coalesce(${printJobs.metadata}, '{}'::jsonb) || ${JSON.stringify({
|
||||||
|
lastDispatchError: event.detail ?? "the gateway reported the job failed",
|
||||||
|
lastDispatchErrorAt: at.toISOString(),
|
||||||
|
})}::jsonb`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
// Re-checked in the write: the status may have moved since the read.
|
||||||
|
.where(and(eq(printJobs.id, job.id), eq(printJobs.status, job.status)))
|
||||||
|
.returning({ id: printJobs.id });
|
||||||
|
|
||||||
|
if (updated.length === 0) return "ignored";
|
||||||
|
console.log(`[dispatch] job ${job.id} ${job.status} -> ${next} (gateway reported ${event.event})`);
|
||||||
|
return "applied";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fails jobs that have been `uploading` too long.
|
* Fails jobs that have been `uploading` too long.
|
||||||
*
|
*
|
||||||
@@ -272,7 +356,7 @@ async function reapStalledUploads(): Promise<void> {
|
|||||||
lastDispatchError:
|
lastDispatchError:
|
||||||
`the gateway accepted the start but the printer never reported printing within ` +
|
`the gateway accepted the start but the printer never reported printing within ` +
|
||||||
`${Math.round(UPLOADING_TIMEOUT_MS / 60_000)} minutes - check the gateway's log for ` +
|
`${Math.round(UPLOADING_TIMEOUT_MS / 60_000)} minutes - check the gateway's log for ` +
|
||||||
`the download or transfer error, which has no path back to the cloud yet`,
|
`the download or transfer error (a failure it reports promptly fails the job sooner)`,
|
||||||
lastDispatchErrorAt: new Date().toISOString(),
|
lastDispatchErrorAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,3 +36,30 @@ export function nextJobStatus(
|
|||||||
return current === "printing" || current === "paused" ? "completed" : undefined;
|
return current === "printing" || current === "paused" ? "completed" : undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a job's status becomes when its gateway reports an outcome it kept
|
||||||
|
* for us (`job_event`) - possibly long after it happened, because the
|
||||||
|
* gateway holds each one until the cloud acknowledges it.
|
||||||
|
*
|
||||||
|
* Only jobs still in flight move. A job the cloud has cancelled, failed or
|
||||||
|
* completed meanwhile is left exactly as it is, which is what makes a stale
|
||||||
|
* report from a gateway harmless; so is `queued`, a job the cloud took back
|
||||||
|
* and may already have given to another printer. `undefined` means leave it.
|
||||||
|
*/
|
||||||
|
export function jobStatusFromGatewayEvent(
|
||||||
|
current: string,
|
||||||
|
event: "started" | "finished" | "failed",
|
||||||
|
): "printing" | "completed" | "failed" | undefined {
|
||||||
|
const inFlight = current === "uploading" || current === "printing" || current === "paused";
|
||||||
|
if (!inFlight) return undefined;
|
||||||
|
switch (event) {
|
||||||
|
case "started":
|
||||||
|
// Only the first step; a job already printing or paused is further on.
|
||||||
|
return current === "uploading" ? "printing" : undefined;
|
||||||
|
case "finished":
|
||||||
|
return "completed";
|
||||||
|
case "failed":
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* A `start_print` is answered the moment the gateway accepts it, because the
|
||||||
|
* download and transfer take minutes and this side stops waiting after ten
|
||||||
|
* seconds. If that work then fails, the gateway sends a second `command_result`
|
||||||
|
* for the same command id - by which time nothing here is waiting for it.
|
||||||
|
*
|
||||||
|
* This remembers which job each start belongs to, so that second result can
|
||||||
|
* fail the job with the gateway's own reason instead of the job sitting in
|
||||||
|
* `uploading` until the stall reaper gives up on it twenty minutes later.
|
||||||
|
*
|
||||||
|
* In memory on purpose: after a restart the reaper is still there to catch
|
||||||
|
* what this forgot.
|
||||||
|
*/
|
||||||
|
const MAX_REMEMBERED = 1000;
|
||||||
|
const REMEMBER_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const starts = new Map<string, { jobId: string; at: number }>();
|
||||||
|
|
||||||
|
export function rememberStart(commandId: string, jobId: string, now = Date.now()): void {
|
||||||
|
starts.set(commandId, { jobId, at: now });
|
||||||
|
// Maps iterate in insertion order, so the oldest entries come first.
|
||||||
|
for (const [id, entry] of starts) {
|
||||||
|
if (starts.size <= MAX_REMEMBERED && now - entry.at < REMEMBER_MS) break;
|
||||||
|
starts.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The job a start command was for, forgetting it: a command is only answered late once. */
|
||||||
|
export function takeStart(commandId: string): string | undefined {
|
||||||
|
const entry = starts.get(commandId);
|
||||||
|
starts.delete(commandId);
|
||||||
|
return entry?.jobId;
|
||||||
|
}
|
||||||
+7
-1
@@ -6,10 +6,12 @@ import { toErrorResponse } from "@continuum/ts-core";
|
|||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { valkey } from "./queue";
|
import { valkey } from "./queue";
|
||||||
import { authRoutes } from "./routes/auth";
|
import { authRoutes } from "./routes/auth";
|
||||||
import { orgRoutes } from "./routes/orgs";
|
import { invitationRoutes, orgRoutes } from "./routes/orgs";
|
||||||
import { farmRoutes } from "./routes/farms";
|
import { farmRoutes } from "./routes/farms";
|
||||||
import { printerRoutes } from "./routes/printers";
|
import { printerRoutes } from "./routes/printers";
|
||||||
import { jobRoutes } from "./routes/jobs";
|
import { jobRoutes } from "./routes/jobs";
|
||||||
|
import { alertRoutes } from "./routes/alerts";
|
||||||
|
import { startAiAlertListener } from "./alerts/listener";
|
||||||
import { edgeWsRoute } from "./routes/ws/edge";
|
import { edgeWsRoute } from "./routes/ws/edge";
|
||||||
import { telemetryWsRoute } from "./routes/ws/telemetry";
|
import { telemetryWsRoute } from "./routes/ws/telemetry";
|
||||||
import { startDispatcher } from "./dispatch";
|
import { startDispatcher } from "./dispatch";
|
||||||
@@ -27,6 +29,7 @@ const app = new Elysia()
|
|||||||
{ name: "farms", description: "Sites, each belonging to one organisation" },
|
{ name: "farms", description: "Sites, each belonging to one organisation" },
|
||||||
{ name: "printers", description: "Machines, each belonging to one farm" },
|
{ name: "printers", description: "Machines, each belonging to one farm" },
|
||||||
{ name: "jobs", description: "Print jobs" },
|
{ name: "jobs", description: "Print jobs" },
|
||||||
|
{ name: "alerts", description: "Things an operator should look at" },
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
@@ -59,9 +62,11 @@ const app = new Elysia()
|
|||||||
})
|
})
|
||||||
.use(authRoutes)
|
.use(authRoutes)
|
||||||
.use(orgRoutes)
|
.use(orgRoutes)
|
||||||
|
.use(invitationRoutes)
|
||||||
.use(farmRoutes)
|
.use(farmRoutes)
|
||||||
.use(printerRoutes)
|
.use(printerRoutes)
|
||||||
.use(jobRoutes)
|
.use(jobRoutes)
|
||||||
|
.use(alertRoutes)
|
||||||
.use(edgeWsRoute)
|
.use(edgeWsRoute)
|
||||||
.use(telemetryWsRoute)
|
.use(telemetryWsRoute)
|
||||||
.listen(Number(process.env.PORT ?? 3000));
|
.listen(Number(process.env.PORT ?? 3000));
|
||||||
@@ -72,5 +77,6 @@ console.log(`continuum-backend listening on :${app.server?.port}`);
|
|||||||
// only ever sends work to farms with a connected gateway, so on a fresh boot
|
// only ever sends work to farms with a connected gateway, so on a fresh boot
|
||||||
// it does nothing until one connects.
|
// it does nothing until one connects.
|
||||||
startDispatcher();
|
startDispatcher();
|
||||||
|
startAiAlertListener();
|
||||||
|
|
||||||
export type App = typeof app;
|
export type App = typeof app;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Invitation tokens and gateway enrolment codes. Pure, so the parts worth
|
||||||
|
* getting exactly right are testable without a database.
|
||||||
|
*/
|
||||||
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
||||||
|
|
||||||
|
export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
export const ENROL_TTL_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
/** What is stored: a hash, never the secret. SHA-256 is enough for 256-bit random input. */
|
||||||
|
export const hashSecret = (secret: string) => createHash("sha256").update(secret).digest("hex");
|
||||||
|
|
||||||
|
/** 256 bits, URL-safe: goes in a link. */
|
||||||
|
export const newInviteToken = () => randomBytes(32).toString("base64url");
|
||||||
|
|
||||||
|
// Crockford base32: no I, L, O or U, so a code read off a screen and typed
|
||||||
|
// into a terminal survives the usual mix-ups.
|
||||||
|
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||||
|
const CODE_LENGTH = 10;
|
||||||
|
|
||||||
|
/** `ABCDE-FGHJK`: 50 bits, single-use and valid for fifteen minutes. */
|
||||||
|
export function newEnrolmentCode(): string {
|
||||||
|
let code = "";
|
||||||
|
for (let i = 0; i < CODE_LENGTH; i++) code += ALPHABET[randomInt(ALPHABET.length)];
|
||||||
|
return `${code.slice(0, 5)}-${code.slice(5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What was typed, reduced to what was issued: case, dashes and spaces ignored, look-alikes folded. */
|
||||||
|
export function normaliseCode(input: string): string {
|
||||||
|
return input
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^0-9A-Z]/g, "")
|
||||||
|
.replace(/O/g, "0")
|
||||||
|
.replace(/[IL]/g, "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The form that is hashed for storage and lookup. */
|
||||||
|
export const codeKey = (input: string) => hashSecret(normaliseCode(input));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts failures per key inside a sliding window, so guessing an enrolment
|
||||||
|
* code from one address stops being worth trying. In memory: it resets on
|
||||||
|
* restart, which only ever helps an attacker by a window's worth of guesses.
|
||||||
|
*/
|
||||||
|
export class FailureLimiter {
|
||||||
|
private failures = new Map<string, number[]>();
|
||||||
|
constructor(
|
||||||
|
private readonly max: number,
|
||||||
|
private readonly windowMs: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
blocked(key: string, now = Date.now()): boolean {
|
||||||
|
const recent = (this.failures.get(key) ?? []).filter((t) => now - t < this.windowMs);
|
||||||
|
if (recent.length === 0) this.failures.delete(key);
|
||||||
|
else this.failures.set(key, recent);
|
||||||
|
return recent.length >= this.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(key: string, now = Date.now()): void {
|
||||||
|
this.failures.set(key, [...(this.failures.get(key) ?? []), now]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Pure helpers for adding a printer through a gateway, with no imports so
|
||||||
|
* they test without a database - same reasoning as `dispatch/job_status.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vendors a gateway can drive, by the name the gateway knows them as, and
|
||||||
|
* what each needs to be told. `prusa_link` is stored as `prusa` and
|
||||||
|
* `octoprint` as itself - the database enum predates the gateway's names.
|
||||||
|
*
|
||||||
|
* Bambu is verified against real printers. The other three are written from
|
||||||
|
* their public APIs and tested against mock servers only.
|
||||||
|
*/
|
||||||
|
export const PROVISIONABLE_VENDORS = ["bambu", "klipper", "octoprint", "prusa_link"] as const;
|
||||||
|
export type ProvisionableVendor = (typeof PROVISIONABLE_VENDORS)[number];
|
||||||
|
|
||||||
|
const NEEDS: Record<ProvisionableVendor, { accessCode?: true; serialNumber?: true; apiKey?: true; dbVendor: "bambu" | "klipper" | "octoprint" | "prusa" }> = {
|
||||||
|
bambu: { accessCode: true, serialNumber: true, dbVendor: "bambu" },
|
||||||
|
klipper: { dbVendor: "klipper" },
|
||||||
|
octoprint: { apiKey: true, dbVendor: "octoprint" },
|
||||||
|
prusa_link: { apiKey: true, dbVendor: "prusa" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dbVendorFor = (vendor: ProvisionableVendor) => NEEDS[vendor].dbVendor;
|
||||||
|
|
||||||
|
/** The first credential this vendor needs that was not supplied, named for the operator. */
|
||||||
|
export function missingCredential(
|
||||||
|
vendor: ProvisionableVendor,
|
||||||
|
given: { accessCode?: string; serialNumber?: string; apiKey?: string },
|
||||||
|
): string | undefined {
|
||||||
|
const needs = NEEDS[vendor];
|
||||||
|
if (needs.accessCode && !given.accessCode?.trim()) return "an access code";
|
||||||
|
if (needs.serialNumber && !given.serialNumber?.trim()) return "a serial number";
|
||||||
|
if (needs.apiKey && !given.apiKey?.trim()) return "an API key";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id the gateway will know this printer by, chosen here so telemetry
|
||||||
|
* arrives under an id the cloud already holds. A readable slug of the name
|
||||||
|
* plus a random suffix: readable because it shows up in the gateway's logs
|
||||||
|
* and certificate-pin file names, random because two printers may share a
|
||||||
|
* name. Constrained to what the gateway accepts (`^[A-Za-z0-9_-]{1,64}$`).
|
||||||
|
*/
|
||||||
|
export function gatewayIdFor(name: string, random: () => number = Math.random): string {
|
||||||
|
const slug =
|
||||||
|
name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 40) || "printer";
|
||||||
|
const suffix = Math.floor(random() * 0xffff)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(4, "0");
|
||||||
|
return `${slug}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to tell the operator when the gateway says no, without ever repeating
|
||||||
|
* the credential back. The gateway's messages do not contain it by design,
|
||||||
|
* and this is the second line of that defence: anything that does is dropped.
|
||||||
|
*/
|
||||||
|
export function safeGatewayError(error: string | undefined, secrets: string[]): string {
|
||||||
|
let message = error ?? "the gateway did not say why";
|
||||||
|
for (const secret of secrets) {
|
||||||
|
if (secret) message = message.split(secret).join("<hidden>");
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
|
import { NotFoundError } from "@continuum/ts-core";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { printerAlerts } from "../db/schema";
|
||||||
|
import { requireAlert, requireUser, visibleAlerts } from "../auth";
|
||||||
|
import { PaginationQuery, paginate } from "./pagination";
|
||||||
|
|
||||||
|
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
|
||||||
|
|
||||||
|
export const alertRoutes = new Elysia({ prefix: "/alerts" })
|
||||||
|
.use(requireUser)
|
||||||
|
.get(
|
||||||
|
"/",
|
||||||
|
async ({ user, query }) => {
|
||||||
|
const filters = [visibleAlerts(user.id)];
|
||||||
|
if (query.farmId) filters.push(eq(printerAlerts.farmId, query.farmId));
|
||||||
|
// Open means not acknowledged: what is still waiting on a person.
|
||||||
|
if (query.open) filters.push(isNull(printerAlerts.acknowledgedAt));
|
||||||
|
return db.query.printerAlerts.findMany({
|
||||||
|
where: and(...filters),
|
||||||
|
orderBy: [desc(printerAlerts.createdAt)],
|
||||||
|
...paginate(query),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: t.Object({
|
||||||
|
farmId: t.Optional(t.String({ format: "uuid" })),
|
||||||
|
open: t.Optional(t.BooleanString()),
|
||||||
|
...PaginationQuery,
|
||||||
|
}),
|
||||||
|
detail: { tags: ["alerts"], summary: "Alerts in the caller's organisations" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* `operator`, not `viewer`: acknowledging clears it from everyone's list, so
|
||||||
|
* it is a decision about the farm and not a read.
|
||||||
|
*
|
||||||
|
* Idempotent on purpose - the second person to click gets the same answer,
|
||||||
|
* and the first acknowledger stays on the record.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
"/:id/acknowledge",
|
||||||
|
async ({ user, params }) => {
|
||||||
|
await requireAlert(user.id, params.id, "operator", "acknowledging an alert");
|
||||||
|
const [row] = await db
|
||||||
|
.update(printerAlerts)
|
||||||
|
.set({ acknowledgedBy: user.id, acknowledgedAt: new Date() })
|
||||||
|
.where(and(eq(printerAlerts.id, params.id), isNull(printerAlerts.acknowledgedAt)))
|
||||||
|
.returning();
|
||||||
|
if (row) return row;
|
||||||
|
const existing = await db.query.printerAlerts.findFirst({
|
||||||
|
where: eq(printerAlerts.id, params.id),
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundError(`Alert ${params.id} not found`);
|
||||||
|
return existing;
|
||||||
|
},
|
||||||
|
{ params: IdParams, detail: { tags: ["alerts"], summary: "Mark an alert as dealt with" } },
|
||||||
|
);
|
||||||
+59
-2
@@ -1,11 +1,15 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import jwt from "@elysiajs/jwt";
|
import jwt from "@elysiajs/jwt";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
import { UnauthorizedError } from "@continuum/ts-core";
|
import { UnauthorizedError } from "@continuum/ts-core";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { farms } from "../db/schema";
|
import { enrolmentCodes, farms } from "../db/schema";
|
||||||
import { listMemberships, requireUser } from "../auth";
|
import { listMemberships, requireUser } from "../auth";
|
||||||
import { edgeJwtSecret } from "../auth/edge_secret";
|
import { edgeJwtSecret } from "../auth/edge_secret";
|
||||||
|
import { FailureLimiter, codeKey } from "../onboarding/tokens";
|
||||||
|
|
||||||
|
/** Ten wrong codes from one address in ten minutes and it is told to wait. */
|
||||||
|
const enrolFailures = new FailureLimiter(10, 10 * 60 * 1000);
|
||||||
|
|
||||||
const EDGE_TOKEN_TTL_SECONDS = 3600;
|
const EDGE_TOKEN_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
@@ -64,6 +68,59 @@ export const authRoutes = new Elysia({ prefix: "/auth" })
|
|||||||
detail: { tags: ["auth"], summary: "Trade a farm's proxy token for an edge JWT" },
|
detail: { tags: ["auth"], summary: "Trade a farm's proxy token for an edge JWT" },
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
/**
|
||||||
|
* A gateway trades a one-time code (`POST /farms/:id/enrolment-codes`) for
|
||||||
|
* its farm's credentials. Not behind `requireUser` for the same reason as
|
||||||
|
* `/edge-token`: the gateway has no account, and the code is the credential.
|
||||||
|
*
|
||||||
|
* The farm's existing token is returned, not rotated, so enrolling a second
|
||||||
|
* machine cannot lock out the first. A farm created before tokens were
|
||||||
|
* minted at creation gets one here.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
"/enrol",
|
||||||
|
async ({ body, server, request, set }) => {
|
||||||
|
const who = server?.requestIP(request)?.address ?? "unknown";
|
||||||
|
if (enrolFailures.blocked(who)) {
|
||||||
|
set.status = 429;
|
||||||
|
return { error: { code: "TOO_MANY_ATTEMPTS", message: "Too many wrong codes; wait a few minutes and try again" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claimed in one statement: of two gateways presenting the same code at
|
||||||
|
// once, exactly one gets a row back.
|
||||||
|
const [claimed] = await db
|
||||||
|
.update(enrolmentCodes)
|
||||||
|
.set({ usedAt: new Date() })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(enrolmentCodes.codeHash, codeKey(body.code)),
|
||||||
|
isNull(enrolmentCodes.usedAt),
|
||||||
|
gt(enrolmentCodes.expiresAt, new Date()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ farmId: enrolmentCodes.farmId });
|
||||||
|
if (!claimed) {
|
||||||
|
enrolFailures.fail(who);
|
||||||
|
throw new UnauthorizedError("That code is wrong, expired or already used");
|
||||||
|
}
|
||||||
|
|
||||||
|
const farm = await db.query.farms.findFirst({
|
||||||
|
columns: { id: true, edgeProxyToken: true },
|
||||||
|
where: eq(farms.id, claimed.farmId),
|
||||||
|
});
|
||||||
|
if (!farm) throw new UnauthorizedError("That code is wrong, expired or already used");
|
||||||
|
let token = farm.edgeProxyToken;
|
||||||
|
if (!token) {
|
||||||
|
token = crypto.randomUUID();
|
||||||
|
await db.update(farms).set({ edgeProxyToken: token, updatedAt: new Date() }).where(eq(farms.id, farm.id));
|
||||||
|
}
|
||||||
|
return { farmId: farm.id, gatewayToken: token };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({ code: t.String({ minLength: 4, maxLength: 64 }) }),
|
||||||
|
detail: { tags: ["auth"], summary: "Trade a one-time enrolment code for a gateway's credentials" },
|
||||||
|
},
|
||||||
|
)
|
||||||
.use(requireUser)
|
.use(requireUser)
|
||||||
/**
|
/**
|
||||||
* Who the caller is and what they can reach. A client calls this once after
|
* Who the caller is and what they can reach. A client calls this once after
|
||||||
|
|||||||
+161
-4
@@ -1,12 +1,21 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { NotFoundError } from "@continuum/ts-core";
|
import { ConflictError, NotFoundError } from "@continuum/ts-core";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { farms, printers } from "../db/schema";
|
import { enrolmentCodes, farms, printers } from "../db/schema";
|
||||||
import { farmProjection } from "../db/projections";
|
import { farmProjection, printerProjection } from "../db/projections";
|
||||||
|
import {
|
||||||
|
PROVISIONABLE_VENDORS,
|
||||||
|
dbVendorFor,
|
||||||
|
gatewayIdFor,
|
||||||
|
missingCredential,
|
||||||
|
safeGatewayError,
|
||||||
|
type ProvisionableVendor,
|
||||||
|
} from "../provision/plan";
|
||||||
import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth";
|
import { requireFarm, requireOrg, requireUser, visibleFarms } from "../auth";
|
||||||
|
import { ENROL_TTL_MS, codeKey, newEnrolmentCode } from "../onboarding/tokens";
|
||||||
import { PaginationQuery, paginate } from "./pagination";
|
import { PaginationQuery, paginate } from "./pagination";
|
||||||
import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters } from "./ws/edge";
|
import { getDiscoveredPrinters, isGatewayOnline, listUnclaimedPrinters, requestGateway } from "./ws/edge";
|
||||||
|
|
||||||
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
|
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
|
||||||
|
|
||||||
@@ -128,6 +137,35 @@ export const farmRoutes = new Elysia({ prefix: "/farms" })
|
|||||||
},
|
},
|
||||||
{ params: IdParams, detail: { tags: ["farms"] } },
|
{ params: IdParams, detail: { tags: ["farms"] } },
|
||||||
)
|
)
|
||||||
|
/**
|
||||||
|
* A one-time code for connecting a gateway to this farm. The gateway
|
||||||
|
* trades it at `POST /auth/enrol` for the farm's credentials, so nobody has
|
||||||
|
* to copy a token between machines.
|
||||||
|
*
|
||||||
|
* Unlike rotating the token, this leaves the farm's existing credential
|
||||||
|
* alone: enrolling a gateway cannot lock out the one already running.
|
||||||
|
* Fifteen minutes, one use, and only its hash is kept.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
"/:id/enrolment-codes",
|
||||||
|
async ({ user, params, set }) => {
|
||||||
|
await requireFarm(user.id, params.id, "owner", "enrolling a gateway");
|
||||||
|
const code = newEnrolmentCode();
|
||||||
|
const expiresAt = new Date(Date.now() + ENROL_TTL_MS);
|
||||||
|
await db.insert(enrolmentCodes).values({
|
||||||
|
farmId: params.id,
|
||||||
|
codeHash: codeKey(code),
|
||||||
|
createdBy: user.id,
|
||||||
|
expiresAt,
|
||||||
|
});
|
||||||
|
set.status = 201;
|
||||||
|
return { code, expiresAt };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: IdParams,
|
||||||
|
detail: { tags: ["farms"], summary: "A one-time code to enrol a gateway (owner only)" },
|
||||||
|
},
|
||||||
|
)
|
||||||
/**
|
/**
|
||||||
* Issues a *new* `edgeProxyToken` for this farm and returns it once.
|
* Issues a *new* `edgeProxyToken` for this farm and returns it once.
|
||||||
*
|
*
|
||||||
@@ -193,6 +231,125 @@ export const farmRoutes = new Elysia({ prefix: "/farms" })
|
|||||||
* its own - it means either "everything is claimed" or "no gateway has ever
|
* its own - it means either "everything is claimed" or "no gateway has ever
|
||||||
* connected", and those need completely different things done about them.
|
* connected", and those need completely different things done about them.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Adds a printer to a farm: tells the farm's gateway to connect to it, and
|
||||||
|
* creates the printer row once it has.
|
||||||
|
*
|
||||||
|
* **The access code (or API key) passes through this process and is kept nowhere.** It
|
||||||
|
* is read from the body, placed in one frame to the gateway, and dropped:
|
||||||
|
* not stored, not logged, not echoed in a response or an error. The
|
||||||
|
* gateway keeps it in its own database. That is the platform's rule for
|
||||||
|
* printer credentials, and it is why this is a request to a *connected*
|
||||||
|
* gateway rather than a row that a gateway picks up later - there is no
|
||||||
|
* place in the cloud for the secret to wait.
|
||||||
|
*
|
||||||
|
* The gateway is asked first and the row created second. The other order
|
||||||
|
* would show a printer in the console that the gateway then refuses
|
||||||
|
* (wrong code, wrong address) and leave cleaning up to us; this way a
|
||||||
|
* failure leaves nothing behind. If the row insert fails after the
|
||||||
|
* gateway said yes, the gateway is told to forget the printer again.
|
||||||
|
*
|
||||||
|
* `owner`, like creating a printer any other way.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
"/:id/printers",
|
||||||
|
async ({ user, params, body, set }) => {
|
||||||
|
await requireFarm(user.id, params.id, "owner", "adding a printer");
|
||||||
|
|
||||||
|
if (!(PROVISIONABLE_VENDORS as readonly string[]).includes(body.vendor)) {
|
||||||
|
set.status = 422;
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: "VENDOR_NOT_SUPPORTED",
|
||||||
|
message: `only ${PROVISIONABLE_VENDORS.join(", ")} printers can be added and driven today`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const vendor = body.vendor as ProvisionableVendor;
|
||||||
|
const missing = missingCredential(vendor, body);
|
||||||
|
if (missing) {
|
||||||
|
set.status = 422;
|
||||||
|
return { error: { code: "CREDENTIAL_REQUIRED", message: `a ${vendor} printer needs ${missing}` } };
|
||||||
|
}
|
||||||
|
if (!isGatewayOnline(params.id)) {
|
||||||
|
throw new ConflictError(
|
||||||
|
"this farm's gateway is not connected, so it cannot be given the printer's credentials - start it and try again",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gatewayPrinterId = gatewayIdFor(body.name);
|
||||||
|
const secrets = [body.accessCode ?? "", body.apiKey ?? ""];
|
||||||
|
const answer = await requestGateway(params.id, (requestId) => ({
|
||||||
|
type: "provision_printer",
|
||||||
|
request_id: requestId,
|
||||||
|
printer: {
|
||||||
|
id: gatewayPrinterId,
|
||||||
|
name: body.name,
|
||||||
|
vendor: body.vendor,
|
||||||
|
host: body.host,
|
||||||
|
access_code: body.accessCode,
|
||||||
|
sn: body.serialNumber,
|
||||||
|
api_key: body.apiKey,
|
||||||
|
},
|
||||||
|
})).catch((err: unknown) => ({
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!answer.ok) {
|
||||||
|
set.status = 502;
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
code: "GATEWAY_REFUSED",
|
||||||
|
message: safeGatewayError(answer.error, secrets),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [printer] = await db
|
||||||
|
.insert(printers)
|
||||||
|
.values({
|
||||||
|
farmId: params.id,
|
||||||
|
name: body.name,
|
||||||
|
vendor: dbVendorFor(vendor),
|
||||||
|
lanAddress: body.host,
|
||||||
|
serialNumber: body.serialNumber,
|
||||||
|
gatewayPrinterId,
|
||||||
|
})
|
||||||
|
.returning(printerProjection.returning);
|
||||||
|
set.status = 201;
|
||||||
|
return printer;
|
||||||
|
} catch (err) {
|
||||||
|
// The gateway now holds a printer the cloud has no row for. Best
|
||||||
|
// effort to take it back out; either way the caller is told.
|
||||||
|
await requestGateway(
|
||||||
|
params.id,
|
||||||
|
(requestId) => ({ type: "remove_printer", request_id: requestId, printer_id: gatewayPrinterId }),
|
||||||
|
10_000,
|
||||||
|
).catch(() => undefined);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: IdParams,
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String({ minLength: 1, maxLength: 200 }),
|
||||||
|
vendor: t.String({ minLength: 1, maxLength: 32 }),
|
||||||
|
// An address or hostname: letters, digits, dots, dashes, colons (IPv6).
|
||||||
|
host: t.String({ minLength: 1, maxLength: 255, pattern: "^[A-Za-z0-9.:_-]+$" }),
|
||||||
|
// Which of these a vendor needs is checked above (`missingCredential`).
|
||||||
|
accessCode: t.Optional(t.String({ minLength: 1, maxLength: 128 })),
|
||||||
|
// Bambu's MQTT topics are addressed by serial number.
|
||||||
|
serialNumber: t.Optional(t.String({ minLength: 5, maxLength: 64, pattern: "^[A-Za-z0-9]+$" })),
|
||||||
|
apiKey: t.Optional(t.String({ minLength: 1, maxLength: 256 })),
|
||||||
|
}),
|
||||||
|
detail: {
|
||||||
|
tags: ["farms"],
|
||||||
|
summary: "Add a printer through the farm's gateway (the access code is relayed, never stored)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
"/:id/unclaimed-printers",
|
"/:id/unclaimed-printers",
|
||||||
async ({ user, params }) => {
|
async ({ user, params }) => {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const jobRoutes = new Elysia({ prefix: "/jobs" })
|
|||||||
farmId: body.farmId,
|
farmId: body.farmId,
|
||||||
fileName: body.fileName,
|
fileName: body.fileName,
|
||||||
r2ObjectKey,
|
r2ObjectKey,
|
||||||
|
metadata: body.amsSlot === undefined ? {} : { amsSlot: body.amsSlot },
|
||||||
// From the verified token, never from the body. It used to be a
|
// From the verified token, never from the body. It used to be a
|
||||||
// `submittedBy` field the caller filled in, which made the audit
|
// `submittedBy` field the caller filled in, which made the audit
|
||||||
// trail a suggestion rather than a record.
|
// trail a suggestion rather than a record.
|
||||||
@@ -89,6 +90,9 @@ export const jobRoutes = new Elysia({ prefix: "/jobs" })
|
|||||||
farmId: t.String({ format: "uuid" }),
|
farmId: t.String({ format: "uuid" }),
|
||||||
fileName: t.String({ minLength: 1, maxLength: 500 }),
|
fileName: t.String({ minLength: 1, maxLength: 500 }),
|
||||||
contentType: t.Optional(t.String()),
|
contentType: t.Optional(t.String()),
|
||||||
|
// 0-based across all AMS units (AMS1 slot 4 is 3). Absent: the
|
||||||
|
// gateway maps by the project's own filament.
|
||||||
|
amsSlot: t.Optional(t.Integer({ minimum: 0, maximum: 15 })),
|
||||||
}),
|
}),
|
||||||
detail: { tags: ["jobs"], summary: "Queue a print job and get an upload URL" },
|
detail: { tags: ["jobs"], summary: "Queue a print job and get an upload URL" },
|
||||||
},
|
},
|
||||||
|
|||||||
+169
-8
@@ -1,9 +1,10 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { and, count, eq, ne } from "drizzle-orm";
|
import { and, count, eq, gt, isNull, ne } from "drizzle-orm";
|
||||||
import { ConflictError, NotFoundError } from "@continuum/ts-core";
|
import { ConflictError, ForbiddenError, NotFoundError } from "@continuum/ts-core";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { farms, orgMembers, organisations, ORG_ROLES } from "../db/schema";
|
import { farms, orgInvitations, orgMembers, organisations, ORG_ROLES } from "../db/schema";
|
||||||
import { listMemberships, requireOrg, requireUser } from "../auth";
|
import { listMemberships, requireOrg, requireUser } from "../auth";
|
||||||
|
import { INVITE_TTL_MS, hashSecret, newInviteToken } from "../onboarding/tokens";
|
||||||
|
|
||||||
const RoleSchema = t.Union(ORG_ROLES.map((role) => t.Literal(role)));
|
const RoleSchema = t.Union(ORG_ROLES.map((role) => t.Literal(role)));
|
||||||
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
|
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
|
||||||
@@ -48,7 +49,7 @@ export const orgRoutes = new Elysia({ prefix: "/orgs" })
|
|||||||
.returning();
|
.returning();
|
||||||
await tx
|
await tx
|
||||||
.insert(orgMembers)
|
.insert(orgMembers)
|
||||||
.values({ orgId: created!.id, userId: user.id, role: "owner" });
|
.values({ orgId: created!.id, userId: user.id, role: "owner", email: user.email?.toLowerCase() });
|
||||||
return created!;
|
return created!;
|
||||||
});
|
});
|
||||||
set.status = 201;
|
set.status = 201;
|
||||||
@@ -125,6 +126,7 @@ export const orgRoutes = new Elysia({ prefix: "/orgs" })
|
|||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
userId: orgMembers.userId,
|
userId: orgMembers.userId,
|
||||||
|
email: orgMembers.email,
|
||||||
role: orgMembers.role,
|
role: orgMembers.role,
|
||||||
joinedAt: orgMembers.createdAt,
|
joinedAt: orgMembers.createdAt,
|
||||||
})
|
})
|
||||||
@@ -138,10 +140,8 @@ export const orgRoutes = new Elysia({ prefix: "/orgs" })
|
|||||||
* states the role this user should have, and sending it twice is the same
|
* states the role this user should have, and sending it twice is the same
|
||||||
* as sending it once.
|
* as sending it once.
|
||||||
*
|
*
|
||||||
* There is no invitation flow yet - this takes a Supabase user id directly,
|
* This takes a Supabase user id directly, which is the right shape for an
|
||||||
* so adding someone means knowing their id. That is the right shape for an
|
* API and the wrong one for a person: they use the invitations below.
|
||||||
* API and the wrong shape for a UI, which will need an invite-by-email
|
|
||||||
* endpoint on top of it.
|
|
||||||
*/
|
*/
|
||||||
.put(
|
.put(
|
||||||
"/:id/members/:userId",
|
"/:id/members/:userId",
|
||||||
@@ -180,6 +180,106 @@ export const orgRoutes = new Elysia({ prefix: "/orgs" })
|
|||||||
detail: { tags: ["orgs"], summary: "Add a member or change their role" },
|
detail: { tags: ["orgs"], summary: "Add a member or change their role" },
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
/**
|
||||||
|
* Invites someone by email. They join when they sign in with that address
|
||||||
|
* and open the link; nobody has to know their user id.
|
||||||
|
*
|
||||||
|
* **No email is sent from here.** The token is returned once, in the
|
||||||
|
* response, for the inviter to pass on (the console builds the link). A
|
||||||
|
* mail provider is a deployment decision this service does not make; until
|
||||||
|
* one is wired up, the inviter is the courier. The address still matters:
|
||||||
|
* only an account with that address can use the link, so a link that ends
|
||||||
|
* up somewhere else is useless to whoever finds it.
|
||||||
|
*
|
||||||
|
* A second invitation to the same address replaces the first, so there is
|
||||||
|
* only ever one live link per person per organisation.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
"/:id/invitations",
|
||||||
|
async ({ user, params, body, set }) => {
|
||||||
|
await requireOrg(user.id, params.id, "owner", "inviting someone");
|
||||||
|
const email = body.email.trim().toLowerCase();
|
||||||
|
const token = newInviteToken();
|
||||||
|
const expiresAt = new Date(Date.now() + INVITE_TTL_MS);
|
||||||
|
|
||||||
|
const invitation = await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.delete(orgInvitations)
|
||||||
|
.where(
|
||||||
|
and(eq(orgInvitations.orgId, params.id), eq(orgInvitations.email, email), isNull(orgInvitations.acceptedAt)),
|
||||||
|
);
|
||||||
|
const [created] = await tx
|
||||||
|
.insert(orgInvitations)
|
||||||
|
.values({
|
||||||
|
orgId: params.id,
|
||||||
|
email,
|
||||||
|
role: body.role,
|
||||||
|
tokenHash: hashSecret(token),
|
||||||
|
invitedBy: user.id,
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: orgInvitations.id,
|
||||||
|
email: orgInvitations.email,
|
||||||
|
role: orgInvitations.role,
|
||||||
|
expiresAt: orgInvitations.expiresAt,
|
||||||
|
});
|
||||||
|
return created!;
|
||||||
|
});
|
||||||
|
set.status = 201;
|
||||||
|
return { ...invitation, token, acceptPath: `/invite?token=${token}` };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: IdParams,
|
||||||
|
body: t.Object({ email: t.String({ format: "email", maxLength: 320 }), role: RoleSchema }),
|
||||||
|
detail: { tags: ["orgs"], summary: "Invite someone by email (the link is returned once)" },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/:id/invitations",
|
||||||
|
async ({ user, params }) => {
|
||||||
|
await requireOrg(user.id, params.id, "owner", "viewing invitations");
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: orgInvitations.id,
|
||||||
|
email: orgInvitations.email,
|
||||||
|
role: orgInvitations.role,
|
||||||
|
createdAt: orgInvitations.createdAt,
|
||||||
|
expiresAt: orgInvitations.expiresAt,
|
||||||
|
})
|
||||||
|
.from(orgInvitations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orgInvitations.orgId, params.id),
|
||||||
|
isNull(orgInvitations.acceptedAt),
|
||||||
|
gt(orgInvitations.expiresAt, new Date()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ params: IdParams, detail: { tags: ["orgs"], summary: "Invitations not yet accepted" } },
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/:id/invitations/:invitationId",
|
||||||
|
async ({ user, params }) => {
|
||||||
|
await requireOrg(user.id, params.id, "owner", "revoking an invitation");
|
||||||
|
const [removed] = await db
|
||||||
|
.delete(orgInvitations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orgInvitations.id, params.invitationId),
|
||||||
|
eq(orgInvitations.orgId, params.id),
|
||||||
|
isNull(orgInvitations.acceptedAt),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: orgInvitations.id });
|
||||||
|
if (!removed) throw new NotFoundError("No such pending invitation");
|
||||||
|
return { revoked: removed.id };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({ id: t.String({ format: "uuid" }), invitationId: t.String({ format: "uuid" }) }),
|
||||||
|
detail: { tags: ["orgs"], summary: "Revoke an invitation" },
|
||||||
|
},
|
||||||
|
)
|
||||||
.delete(
|
.delete(
|
||||||
"/:id/members/:userId",
|
"/:id/members/:userId",
|
||||||
async ({ user, params }) => {
|
async ({ user, params }) => {
|
||||||
@@ -200,3 +300,64 @@ export const orgRoutes = new Elysia({ prefix: "/orgs" })
|
|||||||
},
|
},
|
||||||
{ params: MemberParams, detail: { tags: ["orgs"], summary: "Remove a member" } },
|
{ params: MemberParams, detail: { tags: ["orgs"], summary: "Remove a member" } },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepting an invitation. Separate from `/orgs` because the caller is not
|
||||||
|
* yet a member of anything it could be scoped to.
|
||||||
|
*/
|
||||||
|
export const invitationRoutes = new Elysia({ prefix: "/invitations" })
|
||||||
|
.use(requireUser)
|
||||||
|
.post(
|
||||||
|
"/accept",
|
||||||
|
async ({ user, body }) => {
|
||||||
|
const invitation = await db.query.orgInvitations.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(orgInvitations.tokenHash, hashSecret(body.token)),
|
||||||
|
isNull(orgInvitations.acceptedAt),
|
||||||
|
gt(orgInvitations.expiresAt, new Date()),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
// One answer for unknown, used, revoked and expired: which of those it
|
||||||
|
// was is not for someone holding a guessed or stale link to learn.
|
||||||
|
if (!invitation) throw new NotFoundError("This invitation is invalid, expired or already used");
|
||||||
|
|
||||||
|
if (!user.email) {
|
||||||
|
throw new ForbiddenError("Your sign-in carries no email address, so it cannot be matched to an invitation");
|
||||||
|
}
|
||||||
|
if (user.email.toLowerCase() !== invitation.email) {
|
||||||
|
throw new ForbiddenError(`This invitation was sent to a different address than the one you are signed in with`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rank = (role: string) => ORG_ROLES.indexOf(role as (typeof ORG_ROLES)[number]);
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Claimed first and conditionally: two tabs opening the link at once
|
||||||
|
// cannot both succeed.
|
||||||
|
const [claimed] = await tx
|
||||||
|
.update(orgInvitations)
|
||||||
|
.set({ acceptedAt: new Date(), acceptedBy: user.id })
|
||||||
|
.where(and(eq(orgInvitations.id, invitation.id), isNull(orgInvitations.acceptedAt)))
|
||||||
|
.returning({ id: orgInvitations.id });
|
||||||
|
if (!claimed) throw new ConflictError("This invitation was just used");
|
||||||
|
|
||||||
|
const existing = await tx.query.orgMembers.findFirst({
|
||||||
|
where: and(eq(orgMembers.orgId, invitation.orgId), eq(orgMembers.userId, user.id)),
|
||||||
|
});
|
||||||
|
// An invitation never lowers a role someone already has.
|
||||||
|
const role = existing && rank(existing.role) > rank(invitation.role) ? existing.role : invitation.role;
|
||||||
|
await tx
|
||||||
|
.insert(orgMembers)
|
||||||
|
.values({ orgId: invitation.orgId, userId: user.id, role, email: invitation.email })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [orgMembers.orgId, orgMembers.userId],
|
||||||
|
set: { role, email: invitation.email, updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const org = await db.query.organisations.findFirst({ where: eq(organisations.id, invitation.orgId) });
|
||||||
|
return { orgId: invitation.orgId, orgName: org?.name ?? "", role: invitation.role };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({ token: t.String({ minLength: 20, maxLength: 200 }) }),
|
||||||
|
detail: { tags: ["orgs"], summary: "Accept an invitation with the token from its link" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
+63
-6
@@ -8,9 +8,9 @@ import { PrinterCreateSchema, PrinterUpdateSchema } from "../db/validators";
|
|||||||
import { printerProjection } from "../db/projections";
|
import { printerProjection } from "../db/projections";
|
||||||
import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../auth";
|
import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../auth";
|
||||||
import { PaginationQuery, paginate } from "./pagination";
|
import { PaginationQuery, paginate } from "./pagination";
|
||||||
import { resolveStream } from "../stream";
|
import { resolveSnapshot, resolveStream } from "../stream";
|
||||||
import { mintIceServers, turnConfigured } from "../camera/ice";
|
import { mintIceServers, turnConfigured } from "../camera/ice";
|
||||||
import { isGatewayOnline, negotiateCameraStream, sendPrinterCommand } from "./ws/edge";
|
import { isGatewayOnline, negotiateCameraStream, requestCameraSnapshot, requestGateway, sendPrinterCommand } from "./ws/edge";
|
||||||
|
|
||||||
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
|
const IdParams = t.Object({ id: t.String({ format: "uuid" }) });
|
||||||
|
|
||||||
@@ -93,9 +93,29 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
|
|||||||
.delete(
|
.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
async ({ user, params }) => {
|
async ({ user, params }) => {
|
||||||
await requirePrinter(user.id, params.id, "owner", "deleting a printer");
|
const { farmId } = await requirePrinter(user.id, params.id, "owner", "deleting a printer");
|
||||||
|
const printer = await db.query.printers.findFirst({
|
||||||
|
columns: { gatewayPrinterId: true },
|
||||||
|
where: eq(printers.id, params.id),
|
||||||
|
});
|
||||||
await db.delete(printers).where(eq(printers.id, params.id));
|
await db.delete(printers).where(eq(printers.id, params.id));
|
||||||
return { deleted: params.id };
|
|
||||||
|
// Tell the gateway to forget it - and its stored credential - when it
|
||||||
|
// can be reached. Best effort and after the row is gone: an owner
|
||||||
|
// deleting a printer must not be blocked by an unplugged gateway. If
|
||||||
|
// the gateway misses this, the printer keeps being reported and shows
|
||||||
|
// up as unclaimed, which is visible rather than silently stale.
|
||||||
|
let gatewayForgot: boolean | null = null;
|
||||||
|
const gatewayPrinterId = printer?.gatewayPrinterId;
|
||||||
|
if (gatewayPrinterId && isGatewayOnline(farmId)) {
|
||||||
|
const answer = await requestGateway(
|
||||||
|
farmId,
|
||||||
|
(requestId) => ({ type: "remove_printer", request_id: requestId, printer_id: gatewayPrinterId }),
|
||||||
|
10_000,
|
||||||
|
).catch(() => ({ ok: false }));
|
||||||
|
gatewayForgot = answer.ok;
|
||||||
|
}
|
||||||
|
return { deleted: params.id, gatewayForgot };
|
||||||
},
|
},
|
||||||
{ params: IdParams, detail: { tags: ["printers"] } },
|
{ params: IdParams, detail: { tags: ["printers"] } },
|
||||||
)
|
)
|
||||||
@@ -207,11 +227,22 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
|
|||||||
async ({ user, params }) => {
|
async ({ user, params }) => {
|
||||||
await requirePrinter(user.id, params.id, "viewer");
|
await requirePrinter(user.id, params.id, "viewer");
|
||||||
const printer = await db.query.printers.findFirst({
|
const printer = await db.query.printers.findFirst({
|
||||||
columns: { farmId: true, cameraStream: true },
|
columns: { farmId: true, cameraStream: true, cameraSnapshot: true },
|
||||||
where: eq(printers.id, params.id),
|
where: eq(printers.id, params.id),
|
||||||
});
|
});
|
||||||
if (!printer) throw new NotFoundError(`Printer ${params.id} not found`);
|
if (!printer) throw new NotFoundError(`Printer ${params.id} not found`);
|
||||||
|
|
||||||
|
// A P1/A1: no stream to negotiate, a picture to fetch again and again.
|
||||||
|
if (!printer.cameraStream && printer.cameraSnapshot) {
|
||||||
|
const snap = resolveSnapshot(true, isGatewayOnline(printer.farmId));
|
||||||
|
if (!snap.ok) throw new NotFoundError(snap.message);
|
||||||
|
return {
|
||||||
|
kind: "snapshot" as const,
|
||||||
|
snapshotPath: `/printers/${params.id}/snapshot`,
|
||||||
|
intervalMs: 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// The gateway has to be connected, because it is what answers the
|
// The gateway has to be connected, because it is what answers the
|
||||||
// offer - a camera name alone would start a negotiation with nobody.
|
// offer - a camera name alone would start a negotiation with nobody.
|
||||||
const result = resolveStream(printer.cameraStream, isGatewayOnline(printer.farmId));
|
const result = resolveStream(printer.cameraStream, isGatewayOnline(printer.farmId));
|
||||||
@@ -221,7 +252,7 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
|
|||||||
// credential is useful is small. Never cached in the client beyond the
|
// credential is useful is small. Never cached in the client beyond the
|
||||||
// session it opens.
|
// session it opens.
|
||||||
const iceServers = await mintIceServers();
|
const iceServers = await mintIceServers();
|
||||||
return { ...result.stream, iceServers: iceServers ?? [], turnConfigured: turnConfigured() };
|
return { kind: "webrtc" as const, ...result.stream, iceServers: iceServers ?? [], turnConfigured: turnConfigured() };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
params: IdParams,
|
params: IdParams,
|
||||||
@@ -231,6 +262,32 @@ export const printerRoutes = new Elysia({ prefix: "/printers" })
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
/**
|
||||||
|
* One JPEG frame from a printer whose camera is not RTSP (Bambu P1/A1).
|
||||||
|
*
|
||||||
|
* Viewer-level like the stream. The picture crosses the cloud, which the
|
||||||
|
* WebRTC path avoids; see `requestCameraSnapshot` for why that is bounded.
|
||||||
|
* `no-store` because each fetch must be the next frame, not a cached one.
|
||||||
|
*/
|
||||||
|
.get(
|
||||||
|
"/:id/snapshot",
|
||||||
|
async ({ user, params, set, status }) => {
|
||||||
|
await requirePrinter(user.id, params.id, "viewer");
|
||||||
|
let result: { jpeg?: Buffer; error?: string };
|
||||||
|
try {
|
||||||
|
result = await requestCameraSnapshot(params.id);
|
||||||
|
} catch (err) {
|
||||||
|
return status(404, { error: "NO_SNAPSHOT_CAMERA", message: err instanceof Error ? err.message : String(err) });
|
||||||
|
}
|
||||||
|
if (!result.jpeg) {
|
||||||
|
return status(503, { error: "CAMERA_UNAVAILABLE", message: result.error ?? "no picture" });
|
||||||
|
}
|
||||||
|
set.headers["content-type"] = "image/jpeg";
|
||||||
|
set.headers["cache-control"] = "no-store";
|
||||||
|
return new Response(result.jpeg, { headers: { "content-type": "image/jpeg", "cache-control": "no-store" } });
|
||||||
|
},
|
||||||
|
{ params: IdParams, detail: { tags: ["printers"], summary: "One camera frame (P1/A1)" } },
|
||||||
|
)
|
||||||
/**
|
/**
|
||||||
* Relays a browser's WebRTC offer to the printer's gateway and returns
|
* Relays a browser's WebRTC offer to the printer's gateway and returns
|
||||||
* go2rtc's answer.
|
* go2rtc's answer.
|
||||||
|
|||||||
+170
-8
@@ -9,8 +9,9 @@ import {
|
|||||||
type PrinterTelemetry,
|
type PrinterTelemetry,
|
||||||
} from "@continuum/ts-types";
|
} from "@continuum/ts-types";
|
||||||
import { db } from "../../db";
|
import { db } from "../../db";
|
||||||
import { printers } from "../../db/schema";
|
import { printerAlerts, printers } from "../../db/schema";
|
||||||
import { advanceJobsFrom } from "../../dispatch";
|
import { deriveAlert, type Status } from "../../alerts/derive";
|
||||||
|
import { advanceJobsFrom, applyGatewayJobEvent, failJobFromGateway } from "../../dispatch";
|
||||||
import { enqueueFrameIngestionBatch } from "../../queue";
|
import { enqueueFrameIngestionBatch } from "../../queue";
|
||||||
import { edgeJwtSecret } from "../../auth/edge_secret";
|
import { edgeJwtSecret } from "../../auth/edge_secret";
|
||||||
import { farmFromEdgeUpgrade } from "../../auth/edge_upgrade";
|
import { farmFromEdgeUpgrade } from "../../auth/edge_upgrade";
|
||||||
@@ -74,6 +75,11 @@ interface PendingWebrtc {
|
|||||||
timer: ReturnType<typeof setTimeout>;
|
timer: ReturnType<typeof setTimeout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PendingSnapshot {
|
||||||
|
resolve: (result: { jpeg?: Buffer; error?: string }) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
interface EdgeConn {
|
interface EdgeConn {
|
||||||
farmId: string;
|
farmId: string;
|
||||||
gatewayId: string;
|
gatewayId: string;
|
||||||
@@ -108,6 +114,12 @@ interface EdgeConn {
|
|||||||
* re-query the same mapping every few seconds.
|
* re-query the same mapping every few seconds.
|
||||||
*/
|
*/
|
||||||
printerIds: Map<string, string>;
|
printerIds: Map<string, string>;
|
||||||
|
/**
|
||||||
|
* The last status seen per printer on THIS connection, to tell a change
|
||||||
|
* from a level when deciding whether to raise an alert. Per connection on
|
||||||
|
* purpose: after a reconnect nothing is known, so nothing is raised.
|
||||||
|
*/
|
||||||
|
lastStatus: Map<string, Status>;
|
||||||
/** Commands sent but not yet answered, keyed by command_id. */
|
/** Commands sent but not yet answered, keyed by command_id. */
|
||||||
pending: Map<string, PendingCommand>;
|
pending: Map<string, PendingCommand>;
|
||||||
/**
|
/**
|
||||||
@@ -117,6 +129,8 @@ interface EdgeConn {
|
|||||||
* retried.
|
* retried.
|
||||||
*/
|
*/
|
||||||
webrtc: Map<string, PendingWebrtc>;
|
webrtc: Map<string, PendingWebrtc>;
|
||||||
|
/** In-flight camera frame reads, keyed by request id. */
|
||||||
|
snapshots: Map<string, PendingSnapshot>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const connections = new Map<string, EdgeConn>();
|
const connections = new Map<string, EdgeConn>();
|
||||||
@@ -407,6 +421,7 @@ async function persistTelemetry(conn: EdgeConn, batch: PrinterTelemetry[]) {
|
|||||||
// Cleared like the rest when absent: a printer that lost its stream
|
// Cleared like the rest when absent: a printer that lost its stream
|
||||||
// must stop being offered one.
|
// must stop being offered one.
|
||||||
cameraStream: snapshot.camera_stream ?? null,
|
cameraStream: snapshot.camera_stream ?? null,
|
||||||
|
cameraSnapshot: snapshot.camera_snapshot ?? false,
|
||||||
lastSeenAt: new Date(),
|
lastSeenAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
@@ -432,6 +447,25 @@ async function persistTelemetry(conn: EdgeConn, batch: PrinterTelemetry[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After the write, and never allowed to break the batch: an alert that
|
||||||
|
// fails to save is a lost notification, not a reason to stop recording
|
||||||
|
// the rest of the farm's telemetry.
|
||||||
|
if (updated) {
|
||||||
|
const alert = deriveAlert(
|
||||||
|
conn.lastStatus.get(printerId),
|
||||||
|
status,
|
||||||
|
updated.name,
|
||||||
|
snapshot.state,
|
||||||
|
);
|
||||||
|
if (status) conn.lastStatus.set(printerId, status);
|
||||||
|
if (alert) {
|
||||||
|
await db
|
||||||
|
.insert(printerAlerts)
|
||||||
|
.values({ farmId: conn.farmId, printerId, kind: alert.kind, message: alert.message })
|
||||||
|
.catch((err: unknown) => console.warn("[alerts] could not record an alert", err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The printer's row is the latest snapshot; the job's row is the history
|
// The printer's row is the latest snapshot; the job's row is the history
|
||||||
// that outlives it. This is the only thing that moves a job out of
|
// that outlives it. This is the only thing that moves a job out of
|
||||||
// `uploading` - see `advanceJobsFrom` for why the gateway's `ok` on a
|
// `uploading` - see `advanceJobsFrom` for why the gateway's `ok` on a
|
||||||
@@ -492,6 +526,42 @@ export async function sendPrinterCommand(
|
|||||||
return { ...result, commandId };
|
return { ...result, commandId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Longer than a command: the gateway connects to the new printer before it
|
||||||
|
* answers, and its own connect timeout is 30s.
|
||||||
|
*/
|
||||||
|
const PROVISION_TIMEOUT_MS = 40_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks a farm's gateway to add or forget a printer, and waits for its answer.
|
||||||
|
*
|
||||||
|
* **The message carries a printer credential.** It is built by the caller,
|
||||||
|
* handed straight to the socket, and kept nowhere: not in `pending` (which
|
||||||
|
* holds only the resolver), not in a log line, not in an error. A gateway
|
||||||
|
* that is not connected is a thrown error before anything is built into a
|
||||||
|
* frame, so there is no queue it could sit in either - the console tells the
|
||||||
|
* operator to retry rather than this process holding a secret for later.
|
||||||
|
*/
|
||||||
|
export async function requestGateway(
|
||||||
|
farmId: string,
|
||||||
|
build: (requestId: string) => EdgeServerMessage,
|
||||||
|
timeoutMs = PROVISION_TIMEOUT_MS,
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const wsId = gatewayByFarm.get(farmId);
|
||||||
|
const conn = wsId ? connections.get(wsId) : undefined;
|
||||||
|
if (!conn) throw new Error(`no gateway is connected for this farm`);
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
conn.pending.delete(requestId);
|
||||||
|
resolve({ ok: false, error: `the gateway did not answer within ${Math.round(timeoutMs / 1000)}s` });
|
||||||
|
}, timeoutMs);
|
||||||
|
conn.pending.set(requestId, { resolve, timer });
|
||||||
|
conn.send(build(requestId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relays a browser's WebRTC offer to the gateway that owns this printer and
|
* Relays a browser's WebRTC offer to the gateway that owns this printer and
|
||||||
* returns go2rtc's answer.
|
* returns go2rtc's answer.
|
||||||
@@ -553,6 +623,62 @@ export async function negotiateCameraStream(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A frame this fresh is served again instead of asking the printer. */
|
||||||
|
const SNAPSHOT_MAX_AGE_MS = 800;
|
||||||
|
/** Connect, TLS and a frame from the printer take a few seconds. */
|
||||||
|
const SNAPSHOT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
const snapshotInFlight = new Map<string, Promise<{ jpeg?: Buffer; error?: string }>>();
|
||||||
|
const snapshotCache = new Map<string, { at: number; jpeg: Buffer }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One JPEG frame from a P1/A1-style camera, read by the printer's gateway.
|
||||||
|
*
|
||||||
|
* Unlike the WebRTC path the picture itself crosses the cloud - there is no
|
||||||
|
* codec a browser could be given instead - so this is deliberately small:
|
||||||
|
* one frame per request, concurrent viewers of a printer share one read, and
|
||||||
|
* a frame under a second old is served again. The printer's camera is a
|
||||||
|
* single-client port, and ten browsers must not become ten logins.
|
||||||
|
*
|
||||||
|
* Throws for an unknown printer, one with no snapshot camera, or a farm with
|
||||||
|
* no gateway connected; resolves `{ error }` for a read that failed.
|
||||||
|
*/
|
||||||
|
export async function requestCameraSnapshot(printerId: string): Promise<{ jpeg?: Buffer; error?: string }> {
|
||||||
|
const printer = await db.query.printers.findFirst({
|
||||||
|
columns: { farmId: true, gatewayPrinterId: true, cameraSnapshot: true },
|
||||||
|
where: eq(printers.id, printerId),
|
||||||
|
});
|
||||||
|
if (!printer) throw new Error(`printer ${printerId} not found`);
|
||||||
|
if (!printer.cameraSnapshot) throw new Error(`printer ${printerId} has no snapshot camera`);
|
||||||
|
if (!printer.gatewayPrinterId) throw new Error(`printer ${printerId} is not claimed by a gateway`);
|
||||||
|
|
||||||
|
const cached = snapshotCache.get(printerId);
|
||||||
|
if (cached && Date.now() - cached.at < SNAPSHOT_MAX_AGE_MS) return { jpeg: cached.jpeg };
|
||||||
|
const running = snapshotInFlight.get(printerId);
|
||||||
|
if (running) return running;
|
||||||
|
|
||||||
|
const wsId = gatewayByFarm.get(printer.farmId);
|
||||||
|
const conn = wsId ? connections.get(wsId) : undefined;
|
||||||
|
if (!conn) throw new Error(`no gateway connected for farm ${printer.farmId}`);
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const gatewayPrinterId = printer.gatewayPrinterId;
|
||||||
|
const promise = new Promise<{ jpeg?: Buffer; error?: string }>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
conn.snapshots.delete(requestId);
|
||||||
|
resolve({ error: `gateway did not answer within ${SNAPSHOT_TIMEOUT_MS}ms` });
|
||||||
|
}, SNAPSHOT_TIMEOUT_MS);
|
||||||
|
conn.snapshots.set(requestId, { resolve, timer });
|
||||||
|
conn.send({ type: "camera_snapshot_request", request_id: requestId, printer_id: gatewayPrinterId });
|
||||||
|
}).then((result) => {
|
||||||
|
if (result.jpeg) snapshotCache.set(printerId, { at: Date.now(), jpeg: result.jpeg });
|
||||||
|
snapshotInFlight.delete(printerId);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
snapshotInFlight.set(printerId, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a farm currently has a gateway connected. */
|
/** Whether a farm currently has a gateway connected. */
|
||||||
export function isGatewayOnline(farmId: string): boolean {
|
export function isGatewayOnline(farmId: string): boolean {
|
||||||
const wsId = gatewayByFarm.get(farmId);
|
const wsId = gatewayByFarm.get(farmId);
|
||||||
@@ -616,8 +742,10 @@ export const edgeWsRoute = new Elysia()
|
|||||||
lastSeenAt: Date.now(),
|
lastSeenAt: Date.now(),
|
||||||
send: (message) => ws.send(message),
|
send: (message) => ws.send(message),
|
||||||
printerIds: new Map(),
|
printerIds: new Map(),
|
||||||
|
lastStatus: new Map(),
|
||||||
pending: new Map(),
|
pending: new Map(),
|
||||||
webrtc: new Map(),
|
webrtc: new Map(),
|
||||||
|
snapshots: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
conn.handshakeTimer = setTimeout(() => {
|
conn.handshakeTimer = setTimeout(() => {
|
||||||
@@ -713,6 +841,19 @@ export const edgeWsRoute = new Elysia()
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "camera_snapshot": {
|
||||||
|
const waiting = conn.snapshots.get(message.request_id);
|
||||||
|
if (!waiting) return; // the viewer's request already timed out
|
||||||
|
clearTimeout(waiting.timer);
|
||||||
|
conn.snapshots.delete(message.request_id);
|
||||||
|
waiting.resolve(
|
||||||
|
message.jpeg_base64
|
||||||
|
? { jpeg: Buffer.from(message.jpeg_base64, "base64") }
|
||||||
|
: { error: message.error ?? "the gateway returned no picture" },
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "command_result": {
|
case "command_result": {
|
||||||
const pending = conn.pending.get(message.command_id);
|
const pending = conn.pending.get(message.command_id);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
@@ -726,17 +867,19 @@ export const edgeWsRoute = new Elysia()
|
|||||||
// gives up after ten seconds - so its real outcome can only ever
|
// gives up after ten seconds - so its real outcome can only ever
|
||||||
// arrive late.
|
// arrive late.
|
||||||
//
|
//
|
||||||
// The reason is logged rather than dropped. It is the difference
|
// The reason is logged, and when the command was a start we
|
||||||
// between an operator seeing "job failed" and seeing which half
|
// remember (`rememberStart`), it also fails that job with the
|
||||||
// of a download-then-upload failed and what the printer said.
|
// gateway's own words rather than leaving it in `uploading`.
|
||||||
// It does not change the job's fate: the job never reaching
|
|
||||||
// `printing` is what fails it, and that already works. Recording
|
|
||||||
// it against the job needs a column print_jobs does not have.
|
|
||||||
const reason = message.ok ? "reported success" : `failed: ${message.error ?? "no reason given"}`;
|
const reason = message.ok ? "reported success" : `failed: ${message.error ?? "no reason given"}`;
|
||||||
console.warn(
|
console.warn(
|
||||||
`[edge] gateway ${conn.gatewayId} sent a result for unknown, ` +
|
`[edge] gateway ${conn.gatewayId} sent a result for unknown, ` +
|
||||||
`expired or already-answered command ${message.command_id} - ${reason}`,
|
`expired or already-answered command ${message.command_id} - ${reason}`,
|
||||||
);
|
);
|
||||||
|
if (!message.ok) {
|
||||||
|
failJobFromGateway(message.command_id, message.error ?? "no reason given").catch((err) =>
|
||||||
|
console.error("[edge] could not record a late start failure", err),
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearTimeout(pending.timer);
|
clearTimeout(pending.timer);
|
||||||
@@ -748,6 +891,20 @@ export const edgeWsRoute = new Elysia()
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "job_event": {
|
||||||
|
if (!conn.farmId) return;
|
||||||
|
try {
|
||||||
|
await applyGatewayJobEvent(conn.farmId, message);
|
||||||
|
// Acknowledged whether or not it changed anything: "nothing to
|
||||||
|
// change" is an answer, and the gateway stops sending it.
|
||||||
|
conn.send({ type: "job_event_ack", event_id: message.event_id });
|
||||||
|
} catch (err) {
|
||||||
|
// No ack, so the gateway keeps it and sends it again.
|
||||||
|
console.error(`[edge] could not record job event ${message.event_id} from gateway ${conn.gatewayId}`, err);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "discovered_printers": {
|
case "discovered_printers": {
|
||||||
if (!conn.farmId) return;
|
if (!conn.farmId) return;
|
||||||
// No gateway id until `hello`, and the handshake timer closes a
|
// No gateway id until `hello`, and the handshake timer closes a
|
||||||
@@ -822,6 +979,11 @@ export const edgeWsRoute = new Elysia()
|
|||||||
waiting.resolve({ error: "gateway disconnected before answering" });
|
waiting.resolve({ error: "gateway disconnected before answering" });
|
||||||
conn.webrtc.delete(sessionId);
|
conn.webrtc.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
for (const [requestId, waiting] of conn.snapshots) {
|
||||||
|
clearTimeout(waiting.timer);
|
||||||
|
waiting.resolve({ error: "gateway disconnected before answering" });
|
||||||
|
conn.snapshots.delete(requestId);
|
||||||
|
}
|
||||||
if (conn.farmId && gatewayByFarm.get(conn.farmId) === ws.id) {
|
if (conn.farmId && gatewayByFarm.get(conn.farmId) === ws.id) {
|
||||||
gatewayByFarm.delete(conn.farmId);
|
gatewayByFarm.delete(conn.farmId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,18 @@ export function resolveStream(
|
|||||||
}
|
}
|
||||||
return { ok: true, stream: { cameraId: cameraStream } };
|
return { ok: true, stream: { cameraId: cameraStream } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const NO_SNAPSHOT_MESSAGE = "this printer's camera cannot be read as pictures";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a P1/A1-style camera: no stream to negotiate, a picture to fetch
|
||||||
|
* repeatedly. Same ordering rule as `resolveStream` - the printer first.
|
||||||
|
*/
|
||||||
|
export function resolveSnapshot(
|
||||||
|
cameraSnapshot: boolean | null | undefined,
|
||||||
|
gatewayOnline: boolean,
|
||||||
|
): { ok: true } | { ok: false; reason: "no_camera" | "gateway_offline"; message: string } {
|
||||||
|
if (!cameraSnapshot) return { ok: false, reason: "no_camera", message: NO_SNAPSHOT_MESSAGE };
|
||||||
|
if (!gatewayOnline) return { ok: false, reason: "gateway_offline", message: NO_GATEWAY_MESSAGE };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { deriveAlert, minAiConfidence, worthRaising } from "../src/alerts/derive";
|
||||||
|
|
||||||
|
describe("deriveAlert", () => {
|
||||||
|
test("a fault during a print is a failed print", () => {
|
||||||
|
expect(deriveAlert("printing", "error", "H2C")?.kind).toBe("print_failed");
|
||||||
|
expect(deriveAlert("paused", "error", "H2C")?.kind).toBe("print_failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a fault on an idle printer is a printer error, not a failed print", () => {
|
||||||
|
expect(deriveAlert("idle", "error", "P1P")?.kind).toBe("printer_error");
|
||||||
|
expect(deriveAlert("offline", "error", "P1P")?.kind).toBe("printer_error");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the printer pausing itself is announced, with its own words", () => {
|
||||||
|
const alert = deriveAlert("printing", "paused", "H2C", "PAUSE");
|
||||||
|
expect(alert?.kind).toBe("print_paused");
|
||||||
|
expect(alert?.message).toBe("H2C: the print is paused (printer reports PAUSE)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a level is not an alert: staying in error raises nothing", () => {
|
||||||
|
expect(deriveAlert("error", "error", "H2C")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a printer first seen on this connection raises nothing", () => {
|
||||||
|
expect(deriveAlert(undefined, "error", "H2C")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unmapped status never raises", () => {
|
||||||
|
expect(deriveAlert("printing", undefined, "H2C")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ordinary transitions are quiet", () => {
|
||||||
|
expect(deriveAlert("idle", "printing", "H2C")).toBeUndefined();
|
||||||
|
expect(deriveAlert("printing", "idle", "H2C")).toBeUndefined();
|
||||||
|
expect(deriveAlert("paused", "printing", "H2C")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AI detection floor", () => {
|
||||||
|
test("defaults to 0.6 and respects the environment", () => {
|
||||||
|
expect(minAiConfidence({})).toBe(0.6);
|
||||||
|
expect(minAiConfidence({ ALERT_MIN_CONFIDENCE: "0.9" })).toBe(0.9);
|
||||||
|
});
|
||||||
|
test("an out-of-range or non-numeric floor falls back rather than disabling alerts", () => {
|
||||||
|
expect(minAiConfidence({ ALERT_MIN_CONFIDENCE: "7" })).toBe(0.6);
|
||||||
|
expect(minAiConfidence({ ALERT_MIN_CONFIDENCE: "lots" })).toBe(0.6);
|
||||||
|
});
|
||||||
|
test("worthRaising is inclusive at the floor", () => {
|
||||||
|
expect(worthRaising(0.6, 0.6)).toBe(true);
|
||||||
|
expect(worthRaising(0.59, 0.6)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
import { parsePrintAlert } from "../src/alerts/parse";
|
||||||
|
|
||||||
|
describe("parsePrintAlert", () => {
|
||||||
|
const good = {
|
||||||
|
printer_id: "0b6d3b4e-8f58-4a3b-9c69-9f3a2f6a0a11",
|
||||||
|
label: "spaghetti",
|
||||||
|
confidence: 0.91,
|
||||||
|
captured_at: "2026-10-29T10:00:00Z",
|
||||||
|
r2_object_key: "frames/x.jpg",
|
||||||
|
};
|
||||||
|
test("accepts exactly the published shape", () => {
|
||||||
|
expect(parsePrintAlert(JSON.stringify(good))?.label).toBe("spaghetti");
|
||||||
|
});
|
||||||
|
test("rejects garbage, missing fields and out-of-range confidence instead of throwing", () => {
|
||||||
|
expect(parsePrintAlert("not json")).toBeUndefined();
|
||||||
|
expect(parsePrintAlert(JSON.stringify({ ...good, printer_id: undefined }))).toBeUndefined();
|
||||||
|
expect(parsePrintAlert(JSON.stringify({ ...good, confidence: 3 }))).toBeUndefined();
|
||||||
|
// Tolerant of extra fields: a newer worker adding one must not blind this consumer.
|
||||||
|
expect(parsePrintAlert(JSON.stringify({ ...good, extra: 1 }))?.label).toBe("spaghetti");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { rememberStart, takeStart } from "../src/dispatch/late_failure";
|
||||||
|
|
||||||
|
describe("late start failures", () => {
|
||||||
|
test("a remembered start is returned once, then forgotten", () => {
|
||||||
|
rememberStart("c1", "job-1");
|
||||||
|
expect(takeStart("c1")).toBe("job-1");
|
||||||
|
expect(takeStart("c1")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a command nobody started is unknown", () => {
|
||||||
|
expect(takeStart("never-sent")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an hour-old start is dropped rather than failing a job long since resolved", () => {
|
||||||
|
const longAgo = Date.now() - 2 * 60 * 60 * 1000;
|
||||||
|
rememberStart("old", "job-old", longAgo);
|
||||||
|
rememberStart("new", "job-new");
|
||||||
|
expect(takeStart("old")).toBeUndefined();
|
||||||
|
expect(takeStart("new")).toBe("job-new");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the table is bounded", () => {
|
||||||
|
for (let i = 0; i < 1500; i++) rememberStart(`bulk-${i}`, `job-${i}`);
|
||||||
|
expect(takeStart("bulk-0")).toBeUndefined();
|
||||||
|
expect(takeStart("bulk-1499")).toBe("job-1499");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
import { jobStatusFromGatewayEvent } from "../src/dispatch/job_status";
|
||||||
|
|
||||||
|
describe("jobStatusFromGatewayEvent", () => {
|
||||||
|
test("a finish is believed for any job still in flight, even one never seen printing", () => {
|
||||||
|
for (const current of ["uploading", "printing", "paused"]) {
|
||||||
|
expect(jobStatusFromGatewayEvent(current, "finished")).toBe("completed");
|
||||||
|
expect(jobStatusFromGatewayEvent(current, "failed")).toBe("failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("started only promotes a job that was still uploading", () => {
|
||||||
|
expect(jobStatusFromGatewayEvent("uploading", "started")).toBe("printing");
|
||||||
|
expect(jobStatusFromGatewayEvent("printing", "started")).toBeUndefined();
|
||||||
|
expect(jobStatusFromGatewayEvent("paused", "started")).toBeUndefined();
|
||||||
|
});
|
||||||
|
test("a job the cloud has already settled or taken back is left alone", () => {
|
||||||
|
for (const current of ["queued", "completed", "failed", "cancelled"]) {
|
||||||
|
for (const event of ["started", "finished", "failed"] as const) {
|
||||||
|
expect(jobStatusFromGatewayEvent(current, event)).toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
FailureLimiter,
|
||||||
|
codeKey,
|
||||||
|
hashSecret,
|
||||||
|
newEnrolmentCode,
|
||||||
|
newInviteToken,
|
||||||
|
normaliseCode,
|
||||||
|
} from "../src/onboarding/tokens";
|
||||||
|
|
||||||
|
describe("enrolment codes", () => {
|
||||||
|
test("have the shape that was promised and do not repeat", () => {
|
||||||
|
const codes = new Set(Array.from({ length: 200 }, newEnrolmentCode));
|
||||||
|
expect(codes.size).toBe(200);
|
||||||
|
for (const code of codes) expect(code).toMatch(/^[0-9A-HJKMNP-TV-Z]{5}-[0-9A-HJKMNP-TV-Z]{5}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a code typed carelessly still finds the one that was issued", () => {
|
||||||
|
const issued = "AB0C1-DEF23";
|
||||||
|
for (const typed of ["ab0c1-def23", "AB0C1DEF23", " ab0c1 def23 ", "ABOC1-DEF23", "AB0Cl-DEF23", "ABOCI-DEF23"]) {
|
||||||
|
expect(codeKey(typed)).toBe(codeKey(issued));
|
||||||
|
}
|
||||||
|
expect(codeKey("AB0C1-DEF24")).not.toBe(codeKey(issued));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalising is idempotent", () => {
|
||||||
|
expect(normaliseCode(normaliseCode("ab-oc1"))).toBe(normaliseCode("ab-oc1"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invitation tokens", () => {
|
||||||
|
test("are long, URL-safe and unique, and only their hash is kept", () => {
|
||||||
|
const a = newInviteToken();
|
||||||
|
expect(a).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||||
|
expect(newInviteToken()).not.toBe(a);
|
||||||
|
expect(hashSecret(a)).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(hashSecret(a)).not.toContain(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FailureLimiter", () => {
|
||||||
|
test("blocks after too many failures and forgets them after the window", () => {
|
||||||
|
const limiter = new FailureLimiter(3, 1000);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
expect(limiter.blocked("ip", i)).toBe(false);
|
||||||
|
limiter.fail("ip", i);
|
||||||
|
}
|
||||||
|
expect(limiter.blocked("ip", 10)).toBe(true);
|
||||||
|
expect(limiter.blocked("other", 10)).toBe(false);
|
||||||
|
expect(limiter.blocked("ip", 2000)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { dbVendorFor, gatewayIdFor, missingCredential, safeGatewayError } from "../src/provision/plan";
|
||||||
|
|
||||||
|
describe("gatewayIdFor", () => {
|
||||||
|
const accepted = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
|
|
||||||
|
test("makes a readable id the gateway will accept", () => {
|
||||||
|
expect(gatewayIdFor("Bench P1P", () => 0)).toBe("bench-p1p-0000");
|
||||||
|
expect(gatewayIdFor("Bench P1P", () => 0.999999)).toMatch(accepted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("survives names with nothing usable in them", () => {
|
||||||
|
expect(gatewayIdFor("***", () => 0.5)).toMatch(/^printer-[0-9a-f]{4}$/);
|
||||||
|
expect(gatewayIdFor("Принтер", () => 0.5)).toMatch(accepted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never exceeds what the gateway accepts, whatever the name", () => {
|
||||||
|
expect(gatewayIdFor("x".repeat(500))).toMatch(accepted);
|
||||||
|
expect(gatewayIdFor("../../etc/passwd")).toMatch(accepted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two printers with the same name get different ids", () => {
|
||||||
|
const ids = new Set(Array.from({ length: 50 }, () => gatewayIdFor("Same name")));
|
||||||
|
expect(ids.size).toBeGreaterThan(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("safeGatewayError", () => {
|
||||||
|
test("passes an ordinary message through", () => {
|
||||||
|
expect(safeGatewayError("could not connect to the printer", ["12345678"])).toBe(
|
||||||
|
"could not connect to the printer",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
test("removes a credential that somehow got into the message", () => {
|
||||||
|
expect(safeGatewayError("bad login 12345678 for host", ["12345678"])).toBe("bad login <hidden> for host");
|
||||||
|
});
|
||||||
|
test("has an answer when the gateway gave none", () => {
|
||||||
|
expect(safeGatewayError(undefined, [])).toBe("the gateway did not say why");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("missingCredential", () => {
|
||||||
|
test("a Bambu needs its access code and serial number", () => {
|
||||||
|
expect(missingCredential("bambu", {})).toBe("an access code");
|
||||||
|
expect(missingCredential("bambu", { accessCode: "12345678" })).toBe("a serial number");
|
||||||
|
expect(missingCredential("bambu", { accessCode: "12345678", serialNumber: "01P00A123" })).toBeUndefined();
|
||||||
|
});
|
||||||
|
test("OctoPrint and PrusaLink need an API key; Klipper needs nothing", () => {
|
||||||
|
expect(missingCredential("octoprint", { accessCode: "x" })).toBe("an API key");
|
||||||
|
expect(missingCredential("prusa_link", { apiKey: " " })).toBe("an API key");
|
||||||
|
expect(missingCredential("prusa_link", { apiKey: "k" })).toBeUndefined();
|
||||||
|
expect(missingCredential("klipper", {})).toBeUndefined();
|
||||||
|
});
|
||||||
|
test("the gateway's PrusaLink is stored under the database's older name", () => {
|
||||||
|
expect(dbVendorFor("prusa_link")).toBe("prusa");
|
||||||
|
expect(dbVendorFor("octoprint")).toBe("octoprint");
|
||||||
|
});
|
||||||
|
});
|
||||||
+21
-1
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Value } from "@sinclair/typebox/value";
|
import { Value } from "@sinclair/typebox/value";
|
||||||
import { EdgeClientMessageSchema } from "@continuum/ts-types";
|
import { EdgeClientMessageSchema } from "@continuum/ts-types";
|
||||||
import { NO_CAMERA_MESSAGE, NO_GATEWAY_MESSAGE, resolveStream } from "../src/stream";
|
import { NO_CAMERA_MESSAGE, NO_GATEWAY_MESSAGE, NO_SNAPSHOT_MESSAGE, resolveSnapshot, resolveStream } from "../src/stream";
|
||||||
|
|
||||||
const hello = { type: "hello", gateway_id: "gw-1", farm_id: "f1", token: "t" };
|
const hello = { type: "hello", gateway_id: "gw-1", farm_id: "f1", token: "t" };
|
||||||
|
|
||||||
@@ -63,3 +63,23 @@ describe("resolveStream", () => {
|
|||||||
expect(!both.ok && both.reason).toBe("no_camera");
|
expect(!both.ok && both.reason).toBe("no_camera");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("snapshot cameras (P1/A1)", () => {
|
||||||
|
test("telemetry and the gateway's answer survive the validators", () => {
|
||||||
|
expect(
|
||||||
|
Value.Check(EdgeClientMessageSchema, {
|
||||||
|
type: "telemetry",
|
||||||
|
printers: [{ printer_id: "p1", name: "P1P", camera_snapshot: true }],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(Value.Check(EdgeClientMessageSchema, { type: "camera_snapshot", request_id: "r", jpeg_base64: "/9j/" })).toBe(true);
|
||||||
|
expect(Value.Check(EdgeClientMessageSchema, { type: "camera_snapshot", request_id: "r", error: "no frame" })).toBe(true);
|
||||||
|
expect(Value.Check(EdgeClientMessageSchema, { type: "camera_snapshot", jpeg_base64: "/9j/" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the printer is checked before the gateway", () => {
|
||||||
|
expect(resolveSnapshot(false, false)).toMatchObject({ ok: false, reason: "no_camera", message: NO_SNAPSHOT_MESSAGE });
|
||||||
|
expect(resolveSnapshot(true, false)).toMatchObject({ ok: false, reason: "gateway_offline", message: NO_GATEWAY_MESSAGE });
|
||||||
|
expect(resolveSnapshot(true, true)).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user