major updates to device and links management in admin

This commit is contained in:
user
2026-03-28 15:34:03 +02:00
parent e8c5986df6
commit 6639bcd799
32 changed files with 3304 additions and 496 deletions

View File

@@ -13,6 +13,8 @@ This document defines the laws, principles, and rule sets that govern this codeb
3. **No running scripts** — Do not run build, dev, test, or migrate scripts unless explicitly approved.
4. **No touching migration files** — Do not mess with the `migrations` sql dir, as those are generated manually via drizzle orm
5. **Log meaningful changes** — After completing any meaningful change or activity, append a numbered entry to `memory.log.md` summarizing what was done. This keeps context across sessions.
More rules are only to be added by the human, in case such a suggestion becomes viable.
---

View File

@@ -8,12 +8,13 @@ Currently in alpha. Greenfield. Subject to change.
## How It Works
1. Admin generates a unique link and assigns it to a user (or a slot).
1. Admin generates a unique link and assigns it to a specific Android app on a specific device.
2. User opens that link in their browser — served by `apps/front`.
3. User is shown a loading screen for good UX purposes
4. User is prompted to install the PWA.
5. User opens the PWA — they are routed into a live stream of their assigned Android instance.
6. Admin manages the entire fleet from `apps/main` (the dashboard), which communicates with `apps/orchestrator` running on each VPS to control Docker-Android containers.
3. During the loading flow, `apps/front` validates the link and asks `apps/orchestrator` to reset the assigned Android session and launch the leased app.
4. If that device is already in use by another end user, the link fails instead of taking over the session.
5. User is prompted to install the PWA.
6. User opens the PWA — they are routed into a live stream of their assigned Android app session.
7. Admin manages the entire fleet from `apps/main` (the dashboard), which communicates with `apps/orchestrator` running on each VPS to control Docker-Android containers.
---
@@ -34,7 +35,7 @@ Currently in alpha. Greenfield. Subject to change.
### Device Management (Orchestrator + Admin)
- [ ] Device schema — DB model for a device (host VPS, container ID, status, assigned session, etc.)
- [ ] Device schema — DB model for a device (host VPS, container ID, status, `inUse`, assigned session, etc.)
- [ ] Device domain in `@pkg/logic` — controller + repository + errors
- [ ] Orchestrator command interface — secured Hono routes the admin dashboard calls:
- [ ] `POST /devices/:id/start` — start a Docker-Android container
@@ -42,17 +43,19 @@ Currently in alpha. Greenfield. Subject to change.
- [ ] `POST /devices/:id/restart` — restart a container
- [ ] `GET /devices` — list all devices and their current status
- [ ] `GET /devices/:id` — page to view the device in more detail (info, live stream feed with ws-scrcpy)
- [ ] Device allocation logic — mark a device as in-use for a user session
- [ ] Device release logic — free up a device when a session ends
- [ ] Device allocation logic — atomically mark a device as `inUse` when a validated link starts a session
- [ ] Device release logic — clear `inUse` when a session ends or fails during setup
- [ ] Admin dashboard: Devices page — list fleet, show status, trigger start/stop/restart
- [ ] Internal API key auth between `apps/main` and `apps/orchestrator`
### Link Management (Admin + Front App)
- [ ] Link schema — DB model (unique token, expiry, status, linked device ID)
- [ ] Link schema — DB model (unique token, expiry, status, linked device ID, leased app identity)
- [ ] Link domain in `@pkg/logic` — controller + repository + errors
- [ ] Admin dashboard: Links page — generate links, view detail, configure linked device, revoke, delete
- [ ] Admin dashboard: Links page — generate links, view detail, configure linked device + leased app, revoke, delete
- [ ] `apps/front`: validate incoming link token on request
- [ ] `apps/front`: during loading, reject the link if the assigned device is already `inUse`
- [ ] `apps/front`: call `apps/orchestrator` server-side to clean/reset the device and launch the leased app before handing off the session
- [ ] `apps/front`: return appropriate error page for invalid/expired/revoked links
- [ ] Front: keep on checking for link status change, if it gets revoked, we cutoff the connection

View File

@@ -2,7 +2,7 @@
"name": "@apps/front",
"type": "module",
"scripts": {
"dev": "PORT=3000 tsx watch src/index.ts",
"dev": "PORT=3001 tsx watch src/index.ts",
"build": "tsc",
"prod": "HOST=0.0.0.0 PORT=3000 tsx src/index.ts"
},

View File

@@ -1,5 +1,4 @@
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
import Smartphone from "@lucide/svelte/icons/smartphone";
import { BellRingIcon, Link } from "@lucide/svelte";
import UserCircle from "~icons/lucide/user-circle";
@@ -26,11 +25,6 @@ export const mainNavTree = [
url: "/links",
icon: Link,
},
{
title: "Devices",
url: "/devices",
icon: Smartphone,
},
] as AppSidebarItem[];
export const secondaryNavTree = [
@@ -46,6 +40,18 @@ export const secondaryNavTree = [
},
] as AppSidebarItem[];
export const SUPPORTED_APPS = [
{
title: "Gmail",
packageName: "com.google.android.gm",
},
{
title: "Outlook",
packageName: "com.microsoft.outlook",
},
// will add more here when support increases
];
export const COMPANY_NAME = "SaaS Template";
export const WEBSITE_URL = "https://company.com";

View File

@@ -0,0 +1,80 @@
import { getDeviceByIdSQ } from "./device.remote";
import { toast } from "svelte-sonner";
type Device = {
id: number;
title: string;
version: string;
status: string;
isActive: boolean;
inUse: boolean;
containerId: string;
host: string;
wsPort: string;
createdAt: Date | string;
updatedAt: Date | string;
};
function normalizeViewerUrl(host: string, wsPort: string): string | null {
const trimmedHost = host.trim();
if (!trimmedHost) return null;
if (trimmedHost.startsWith("http://") || trimmedHost.startsWith("https://")) {
try {
const url = new URL(trimmedHost);
if (!url.port) url.port = wsPort;
return url.toString();
} catch {
return trimmedHost;
}
}
const hostWithProtocol = `https://${trimmedHost}`;
try {
const url = new URL(hostWithProtocol);
if (!url.port) url.port = wsPort;
return url.toString();
} catch {
return null;
}
}
class DeviceDetailsViewModel {
device = $state<Device | null>(null);
loading = $state(false);
currentId = $state<number | null>(null);
get streamUrl(): string | null {
if (!this.device) return null;
return normalizeViewerUrl(this.device.host, this.device.wsPort);
}
async fetchDevice(id: number) {
this.loading = true;
this.currentId = id;
try {
const result = await getDeviceByIdSQ({ id });
if (result?.error || !result?.data) {
this.device = null;
toast.error(result?.error?.message || "Failed to fetch device", {
description: result?.error?.description || "Please try again",
});
return;
}
this.device = result.data as Device;
} catch (error) {
this.device = null;
toast.error("Failed to fetch device", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
this.loading = false;
}
}
}
export const deviceDetailsVM = new DeviceDetailsViewModel();

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Switch } from "$lib/components/ui/switch";
type DeviceStatus = "online" | "offline" | "busy" | "error";
let {
fieldPrefix = "device-form",
title = $bindable(""),
version = $bindable(""),
host = $bindable(""),
containerId = $bindable(""),
wsPort = $bindable(""),
isActive = $bindable(false),
inUse = $bindable(false),
status = $bindable("offline" as DeviceStatus),
submitLabel = "Save",
submitting = false,
showAdvanced = false,
onsubmit,
oncancel = undefined,
}: {
fieldPrefix?: string;
title?: string;
version?: string;
host?: string;
containerId?: string;
wsPort?: string;
isActive?: boolean;
inUse?: boolean;
status?: DeviceStatus;
submitLabel?: string;
submitting?: boolean;
showAdvanced?: boolean;
onsubmit: (event: Event) => void | Promise<void>;
oncancel?: (() => void) | undefined;
} = $props();
</script>
<form onsubmit={onsubmit} class="space-y-4">
<div class="space-y-2">
<Label for={`${fieldPrefix}-title`}>Title</Label>
<Input
id={`${fieldPrefix}-title`}
placeholder="e.g. Android 14 Slot 1"
bind:value={title}
required
/>
</div>
<div class="space-y-2">
<Label for={`${fieldPrefix}-host`}>Host</Label>
<Input
id={`${fieldPrefix}-host`}
placeholder="e.g. iotam-ws-scrcpy.snapyra.com"
bind:value={host}
required
/>
</div>
<div class="space-y-2">
<Label for={`${fieldPrefix}-version`}>Version</Label>
<Input
id={`${fieldPrefix}-version`}
placeholder="e.g. android-14"
bind:value={version}
required
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for={`${fieldPrefix}-containerId`}>Container ID</Label>
<Input
id={`${fieldPrefix}-containerId`}
placeholder="e.g. redroid-slot-1"
bind:value={containerId}
required
/>
</div>
<div class="space-y-2">
<Label for={`${fieldPrefix}-wsPort`}>WS Port</Label>
<Input
id={`${fieldPrefix}-wsPort`}
placeholder="e.g. 8886"
bind:value={wsPort}
required
/>
</div>
</div>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<Switch id={`${fieldPrefix}-isActive`} bind:checked={isActive} />
<Label for={`${fieldPrefix}-isActive`}>Active</Label>
</div>
{#if showAdvanced}
<div class="flex items-center gap-2">
<Switch id={`${fieldPrefix}-inUse`} bind:checked={inUse} />
<Label for={`${fieldPrefix}-inUse`}>In Use</Label>
</div>
{/if}
</div>
{#if showAdvanced}
<div class="space-y-2">
<Label for={`${fieldPrefix}-status`}>Status</Label>
<select
id={`${fieldPrefix}-status`}
bind:value={status}
class="border-input bg-background ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1"
>
<option value="online">online</option>
<option value="offline">offline</option>
<option value="busy">busy</option>
<option value="error">error</option>
</select>
</div>
{/if}
<div class="flex justify-end gap-2">
{#if oncancel}
<Button variant="outline" type="button" onclick={oncancel}>
Cancel
</Button>
{/if}
<Button type="submit" disabled={submitting}>
{submitting ? "Saving..." : submitLabel}
</Button>
</div>
</form>

View File

@@ -3,6 +3,7 @@ import {
createDeviceSC,
deleteDeviceSC,
setDeviceStatusSC,
updateDeviceSC,
} from "./device.remote";
import { toast } from "svelte-sonner";
@@ -12,9 +13,10 @@ type Device = {
version: string;
status: string;
isActive: boolean;
containerId: string | null;
inUse: boolean;
containerId: string;
host: string;
wsPort: string | null;
wsPort: string;
createdAt: Date;
updatedAt: Date;
};
@@ -23,17 +25,24 @@ type CreateDeviceInput = {
title: string;
version: string;
host: string;
containerId?: string;
wsPort?: string;
containerId: string;
wsPort: string;
isActive?: boolean;
};
type UpdateDeviceInput = CreateDeviceInput & {
status: "online" | "offline" | "busy" | "error";
inUse: boolean;
};
class DeviceViewModel {
devices = $state<Device[]>([]);
loading = $state(false);
creating = $state(false);
updating = $state(false);
deletingId = $state<number | null>(null);
showCreateDialog = $state(false);
editingId = $state<number | null>(null);
async fetchDevices() {
this.loading = true;
@@ -123,6 +132,43 @@ class DeviceViewModel {
}
}
async updateDevice(id: number, data: UpdateDeviceInput): Promise<boolean> {
this.updating = true;
this.editingId = id;
try {
const result = await updateDeviceSC({ id, data });
if (result?.error) {
toast.error(
result.error.message || "Failed to update device",
{
description:
result.error.description || "Please try again",
},
);
return false;
}
toast.success("Device updated");
if (result.data) {
const updated = result.data as Device;
this.devices = this.devices.map((device) =>
device.id === id ? updated : device,
);
}
return true;
} catch (error) {
toast.error("Failed to update device", {
description:
error instanceof Error
? error.message
: "Please try again",
});
return false;
} finally {
this.updating = false;
this.editingId = null;
}
}
async setStatus(id: number, status: string) {
try {
const result = await setDeviceStatusSC({

View File

@@ -1,5 +1,5 @@
import { getLinkController } from "@pkg/logic/domains/link/controller";
import { updateLinkSchema } from "@pkg/logic/domains/link/data";
import { createLinkSchema, updateLinkSchema } from "@pkg/logic/domains/link/data";
import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils";
import { command, getRequestEvent, query } from "$app/server";
import * as v from "valibot";
@@ -32,10 +32,7 @@ export const getLinkByIdSQ = query(
);
export const createLinkSC = command(
v.object({
linkedDeviceId: v.optional(v.nullable(v.number())),
expiresAt: v.optional(v.nullable(v.date())),
}),
v.omit(createLinkSchema, ["token"]),
async (payload) => {
const event = getRequestEvent();
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);

View File

@@ -12,6 +12,8 @@ type Link = {
id: number;
token: string;
status: string;
appName: string;
appPackage: string;
linkedDeviceId: number | null;
expiresAt: Date | null;
lastAccessedAt: Date | null;
@@ -24,6 +26,7 @@ type DeviceOption = {
title: string;
host: string;
status: string;
inUse: boolean;
};
class LinkViewModel {
@@ -71,6 +74,7 @@ class LinkViewModel {
title: d.title,
host: d.host,
status: d.status,
inUse: d.inUse,
}));
}
} catch {
@@ -79,7 +83,9 @@ class LinkViewModel {
}
async createLink(data: {
linkedDeviceId?: number | null;
linkedDeviceId: number;
appName: string;
appPackage: string;
expiresAt?: Date | null;
}): Promise<boolean> {
this.creating = true;

View File

@@ -1,11 +1,511 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { Badge } from "$lib/components/ui/badge";
import { Button, buttonVariants } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog";
import * as Table from "$lib/components/ui/table";
import { goto } from "$app/navigation";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { mainNavTree } from "$lib/core/constants";
import DeviceForm from "$lib/domains/device/device-form.svelte";
import { deviceVM } from "$lib/domains/device/device.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import ChevronRight from "@lucide/svelte/icons/chevron-right";
import Pencil from "@lucide/svelte/icons/pencil";
import Monitor from "@lucide/svelte/icons/monitor";
import Plus from "@lucide/svelte/icons/plus";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
import Trash2 from "@lucide/svelte/icons/trash-2";
import { onMount } from "svelte";
breadcrumbs.set([mainNavTree[0]]);
let title = $state("");
let version = $state("");
let host = $state("");
let containerId = $state("");
let wsPort = $state("");
let isActive = $state(false);
let editId = $state<number | null>(null);
let editTitle = $state("");
let editVersion = $state("");
let editHost = $state("");
let editContainerId = $state("");
let editWsPort = $state("");
let editIsActive = $state(false);
let editInUse = $state(false);
let editStatus = $state("offline" as
| "online"
| "offline"
| "busy"
| "error");
let showEditDialog = $state(false);
onMount(async () => {
await deviceVM.fetchDevices();
});
function resetForm() {
title = "";
version = "";
host = "";
containerId = "";
wsPort = "";
isActive = false;
}
function openEditDialog(device: (typeof deviceVM.devices)[number]) {
editId = device.id;
editTitle = device.title;
editVersion = device.version;
editHost = device.host;
editContainerId = device.containerId;
editWsPort = device.wsPort;
editIsActive = device.isActive;
editInUse = device.inUse;
editStatus = device.status as typeof editStatus;
showEditDialog = true;
}
function resetEditForm() {
editId = null;
editTitle = "";
editVersion = "";
editHost = "";
editContainerId = "";
editWsPort = "";
editIsActive = false;
editInUse = false;
editStatus = "offline";
}
async function handleCreate(e: Event) {
e.preventDefault();
const success = await deviceVM.createDevice({
title,
version,
host,
containerId,
wsPort,
isActive,
});
if (success) resetForm();
}
async function handleUpdate(e: Event) {
e.preventDefault();
if (!editId) return;
const success = await deviceVM.updateDevice(editId, {
title: editTitle,
version: editVersion,
host: editHost,
containerId: editContainerId,
wsPort: editWsPort,
isActive: editIsActive,
inUse: editInUse,
status: editStatus,
});
if (success) {
showEditDialog = false;
resetEditForm();
}
}
function statusColor(status: string): string {
switch (status) {
case "online":
return "bg-green-500";
case "offline":
return "bg-zinc-400";
case "busy":
return "bg-amber-500";
case "error":
return "bg-red-500";
default:
return "bg-zinc-400";
}
}
function openDeviceDetails(id: number) {
void goto(`/dashboard/${id}`);
}
$effect(() => {
if (showEditDialog) return;
resetEditForm();
});
</script>
<MaxWidthWrapper cls="space-y-4">
<span>dunno</span>
<Card.Root>
<Card.Header>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex items-center gap-2">
<Icon icon={Monitor} cls="h-5 w-5 text-primary" />
<Card.Title>Devices</Card.Title>
<span class="text-muted-foreground text-xs">
{deviceVM.devices.length} total
</span>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => void deviceVM.fetchDevices()}
disabled={deviceVM.loading}
>
<Icon
icon={RefreshCw}
cls={`h-4 w-4 mr-2 ${deviceVM.loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button
size="sm"
onclick={() => (deviceVM.showCreateDialog = true)}
>
<Icon icon={Plus} cls="h-4 w-4 mr-2" />
Add Device
</Button>
</div>
</div>
</Card.Header>
<Card.Content>
{#if !deviceVM.loading && deviceVM.devices.length === 0}
<div class="py-10 text-center text-sm text-muted-foreground">
No devices registered yet.
</div>
{:else}
<!-- Mobile cards -->
<div class="space-y-3 md:hidden">
{#each deviceVM.devices as device (device.id)}
<div class="rounded-lg border bg-background p-3">
<div
class="flex items-start justify-between gap-3"
>
<button
type="button"
class="group min-w-0 text-left"
onclick={() => openDeviceDetails(device.id)}
>
<div class="flex items-center gap-1.5">
<p
class="truncate text-[15px] font-semibold text-primary underline decoration-primary/40 underline-offset-4"
>
{device.title}
</p>
<Icon
icon={ChevronRight}
cls="h-3.5 w-3.5 shrink-0 text-primary/70 transition-transform group-hover:translate-x-0.5"
/>
</div>
<p
class="text-muted-foreground truncate text-xs"
>
{device.host}
</p>
</button>
<span
class="inline-flex items-center gap-1.5 text-xs capitalize"
>
<span
class={`h-2 w-2 rounded-full ${statusColor(device.status)}`}
></span>
{device.status}
</span>
</div>
<div
class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs"
>
<div>
<p class="text-muted-foreground">Version</p>
<p>{device.version}</p>
</div>
<div>
<p class="text-muted-foreground">Active</p>
<p>{device.isActive ? "Yes" : "No"}</p>
</div>
<div>
<p class="text-muted-foreground">In Use</p>
<p>{device.inUse ? "Yes" : "No"}</p>
</div>
<div>
<p class="text-muted-foreground">
Container
</p>
<p class="truncate font-mono">{device.containerId}</p>
</div>
<div>
<p class="text-muted-foreground">
WS Port
</p>
<p>{device.wsPort}</p>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={() => openEditDialog(device)}
>
<Icon icon={Pencil} cls="h-4 w-4" />
</Button>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={deviceVM.deletingId ===
device.id}
>
<Icon icon={Trash2} cls="h-4 w-4" />
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete device?
</AlertDialog.Title>
<AlertDialog.Description>
This will permanently remove
"{device.title}" and unlink it
from any associated links.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() =>
deviceVM.deleteDevice(
device.id,
)}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
</div>
{/each}
</div>
<!-- Desktop table -->
<div class="hidden md:block">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Host</Table.Head>
<Table.Head>Version</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Active</Table.Head>
<Table.Head>In Use</Table.Head>
<Table.Head>Container</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each deviceVM.devices as device (device.id)}
<Table.Row>
<Table.Cell class="font-medium">
<button
type="button"
class="group inline-flex items-center gap-1.5 text-left"
onclick={() =>
openDeviceDetails(device.id)}
>
<span
class="text-[15px] font-semibold text-primary underline decoration-primary/40 underline-offset-4"
>
{device.title}
</span>
<Icon
icon={ChevronRight}
cls="h-3.5 w-3.5 shrink-0 text-primary/70 transition-transform group-hover:translate-x-0.5"
/>
</button>
</Table.Cell>
<Table.Cell>
<span class="font-mono text-xs">
{device.host}
</span>
{#if device.wsPort}
<span
class="text-muted-foreground text-xs"
>
:{device.wsPort}
</span>
{/if}
</Table.Cell>
<Table.Cell>{device.version}</Table.Cell>
<Table.Cell>
<span
class="inline-flex items-center gap-1.5 text-xs capitalize"
>
<span
class={`h-2 w-2 rounded-full ${statusColor(device.status)}`}
></span>
{device.status}
</span>
</Table.Cell>
<Table.Cell>
{#if device.isActive}
<Badge variant="default"
>Active</Badge
>
{:else}
<Badge variant="secondary"
>Inactive</Badge
>
{/if}
</Table.Cell>
<Table.Cell>
{#if device.inUse}
<Badge variant="destructive"
>In Use</Badge
>
{:else}
<Badge variant="secondary"
>Free</Badge
>
{/if}
</Table.Cell>
<Table.Cell>
<span
class="inline-block max-w-[120px] truncate font-mono text-xs"
>
{device.containerId}
</span>
</Table.Cell>
<Table.Cell>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onclick={() =>
openEditDialog(device)}
>
<Icon
icon={Pencil}
cls="h-4 w-4"
/>
Edit
</Button>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={deviceVM.deletingId ===
device.id}
>
<Icon
icon={Trash2}
cls="h-4 w-4"
/>
Delete
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete device?
</AlertDialog.Title>
<AlertDialog.Description>
This will permanently
remove "{device.title}"
and unlink it from any
associated links.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() =>
deviceVM.deleteDevice(
device.id,
)}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</MaxWidthWrapper>
<!-- Create Device Dialog -->
<Dialog.Root bind:open={deviceVM.showCreateDialog}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Add Device</Dialog.Title>
<Dialog.Description>
Register a new Docker-Android instance.
</Dialog.Description>
</Dialog.Header>
<DeviceForm
fieldPrefix="create-device"
bind:title
bind:version
bind:host
bind:containerId
bind:wsPort
bind:isActive
submitLabel="Create"
submitting={deviceVM.creating}
onsubmit={handleCreate}
oncancel={() => (deviceVM.showCreateDialog = false)}
/>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={showEditDialog}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Edit Device</Dialog.Title>
<Dialog.Description>
Update connection details or manually override device state.
</Dialog.Description>
</Dialog.Header>
<DeviceForm
fieldPrefix="edit-device"
bind:title={editTitle}
bind:version={editVersion}
bind:host={editHost}
bind:containerId={editContainerId}
bind:wsPort={editWsPort}
bind:isActive={editIsActive}
bind:inUse={editInUse}
bind:status={editStatus}
submitLabel="Save Changes"
submitting={deviceVM.updating}
showAdvanced={true}
onsubmit={handleUpdate}
oncancel={() => {
showEditDialog = false;
resetEditForm();
}}
/>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,388 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { Badge } from "$lib/components/ui/badge";
import { Button, buttonVariants } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Separator } from "$lib/components/ui/separator";
import { Skeleton } from "$lib/components/ui/skeleton";
import { mainNavTree } from "$lib/core/constants";
import DeviceForm from "$lib/domains/device/device-form.svelte";
import { deviceDetailsVM } from "$lib/domains/device/device-details.vm.svelte";
import { deviceVM } from "$lib/domains/device/device.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import { page } from "$app/state";
import ExternalLink from "@lucide/svelte/icons/external-link";
import MonitorSmartphone from "@lucide/svelte/icons/monitor-smartphone";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
import Server from "@lucide/svelte/icons/server";
import Smartphone from "@lucide/svelte/icons/smartphone";
import { onMount } from "svelte";
const deviceId = $derived(Number(page.params.id));
const currentDevice = $derived(deviceDetailsVM.device);
const streamUrl = $derived(deviceDetailsVM.streamUrl);
let editTitle = $state("");
let editVersion = $state("");
let editHost = $state("");
let editContainerId = $state("");
let editWsPort = $state("");
let editIsActive = $state(false);
let editInUse = $state(false);
let editStatus = $state("offline" as
| "online"
| "offline"
| "busy"
| "error");
function formatDate(value: Date | string | null | undefined): string {
if (!value) return "—";
return new Date(value).toLocaleString();
}
function statusVariant(
status: string,
): "default" | "secondary" | "destructive" | "outline" {
switch (status) {
case "online":
return "default";
case "busy":
return "outline";
case "error":
return "destructive";
default:
return "secondary";
}
}
function setPageBreadcrumbs(title?: string) {
breadcrumbs.set([
mainNavTree[0],
{
title: title || `Device #${page.params.id}`,
url: page.url.pathname,
},
]);
}
async function loadDevice() {
if (!Number.isFinite(deviceId) || deviceId <= 0) return;
await deviceDetailsVM.fetchDevice(deviceId);
setPageBreadcrumbs(deviceDetailsVM.device?.title);
}
function syncEditForm() {
if (!currentDevice) return;
editTitle = currentDevice.title;
editVersion = currentDevice.version;
editHost = currentDevice.host;
editContainerId = currentDevice.containerId;
editWsPort = currentDevice.wsPort;
editIsActive = currentDevice.isActive;
editInUse = currentDevice.inUse;
editStatus = currentDevice.status as typeof editStatus;
}
async function handleUpdate(e: Event) {
e.preventDefault();
if (!currentDevice) return;
const success = await deviceVM.updateDevice(currentDevice.id, {
title: editTitle,
version: editVersion,
host: editHost,
containerId: editContainerId,
wsPort: editWsPort,
isActive: editIsActive,
inUse: editInUse,
status: editStatus,
});
if (!success) return;
await loadDevice();
}
onMount(async () => {
setPageBreadcrumbs();
await loadDevice();
});
$effect(() => {
setPageBreadcrumbs(currentDevice?.title);
});
$effect(() => {
if (!currentDevice) return;
syncEditForm();
});
$effect(() => {
if (!Number.isFinite(deviceId) || deviceId <= 0) return;
if (deviceDetailsVM.currentId === deviceId) return;
void loadDevice();
});
</script>
<MaxWidthWrapper cls="space-y-4">
<Card.Root>
<Card.Header>
<div
class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"
>
<div class="space-y-2">
<div class="flex items-center gap-2">
<Icon icon={Smartphone} cls="h-5 w-5 text-primary" />
<Card.Title>
{currentDevice?.title || `Device #${page.params.id}`}
</Card.Title>
</div>
{#if currentDevice}
<div
class="text-muted-foreground flex flex-wrap items-center gap-2 text-sm"
>
<Badge variant={statusVariant(currentDevice.status)}>
{currentDevice.status}
</Badge>
<span>
{currentDevice.isActive ? "Active" : "Inactive"}
</span>
<span>
{currentDevice.inUse ? "In Use" : "Available"}
</span>
<span>Version {currentDevice.version}</span>
</div>
{/if}
</div>
<div class="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => void loadDevice()}
disabled={deviceDetailsVM.loading}
>
<Icon
icon={RefreshCw}
cls={`mr-2 h-4 w-4 ${deviceDetailsVM.loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
{#if streamUrl}
<a
href={streamUrl}
target="_blank"
rel="noreferrer"
class={buttonVariants({
variant: "outline",
size: "sm",
})}
>
<Icon icon={ExternalLink} cls="mr-2 h-4 w-4" />
Open Stream
</a>
{/if}
</div>
</div>
</Card.Header>
<Card.Content class="space-y-4">
{#if deviceDetailsVM.loading && !currentDevice}
<div class="grid gap-4 xl:grid-cols-[340px_minmax(0,1fr)]">
<Card.Root>
<Card.Content class="space-y-3 pt-6">
<Skeleton class="h-6 w-40" />
<Skeleton class="h-16 w-full" />
<Skeleton class="h-16 w-full" />
<Skeleton class="h-16 w-full" />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Content class="space-y-3 pt-6">
<Skeleton class="h-6 w-48" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
</Card.Content>
</Card.Root>
</div>
<Card.Root>
<Card.Content class="pt-6">
<Skeleton class="h-[480px] w-full rounded-xl" />
</Card.Content>
</Card.Root>
{:else if !currentDevice}
<div class="rounded-xl border border-dashed p-10 text-center">
<p class="text-sm font-medium">Device not found</p>
<p class="text-muted-foreground mt-1 text-sm">
This device could not be loaded from the admin API.
</p>
</div>
{:else}
<!-- Top row: Metadata + Edit Device side by side -->
<div class="grid gap-4 xl:grid-cols-[340px_minmax(0,1fr)]">
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<Icon icon={Server} cls="h-4 w-4 text-primary" />
<Card.Title class="text-base">
Metadata
</Card.Title>
</div>
</Card.Header>
<Card.Content class="space-y-4">
<div class="grid gap-3 text-sm">
<div>
<p class="text-muted-foreground text-xs">Device ID</p>
<p class="font-medium">{currentDevice.id}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">Host</p>
<p class="break-all font-mono text-xs">
{currentDevice.host}
</p>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<p class="text-muted-foreground text-xs">WS Port</p>
<p>{currentDevice.wsPort}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">In Use</p>
<p>{currentDevice.inUse ? "Yes" : "No"}</p>
</div>
</div>
<div>
<p class="text-muted-foreground text-xs">Container ID</p>
<p class="break-all font-mono text-xs">
{currentDevice.containerId}
</p>
</div>
</div>
<Separator />
<div class="grid gap-3 text-sm">
<div class="grid grid-cols-2 gap-3">
<div>
<p class="text-muted-foreground text-xs">Created</p>
<p class="text-xs">{formatDate(currentDevice.createdAt)}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">Updated</p>
<p class="text-xs">{formatDate(currentDevice.updatedAt)}</p>
</div>
</div>
<div>
<p class="text-muted-foreground text-xs">
Viewer URL
</p>
<p class="break-all font-mono text-xs">
{streamUrl || "Missing host configuration"}
</p>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center gap-2">
<Icon icon={RefreshCw} cls="h-4 w-4 text-primary" />
<Card.Title class="text-base">
Edit Device
</Card.Title>
</div>
<Card.Description>
Manually update connection details or override
<code class="bg-muted rounded px-1 text-xs">isActive</code>,
<code class="bg-muted rounded px-1 text-xs">inUse</code>, and
<code class="bg-muted rounded px-1 text-xs">status</code> when needed.
</Card.Description>
</Card.Header>
<Card.Content>
<DeviceForm
fieldPrefix="detail-device"
bind:title={editTitle}
bind:version={editVersion}
bind:host={editHost}
bind:containerId={editContainerId}
bind:wsPort={editWsPort}
bind:isActive={editIsActive}
bind:inUse={editInUse}
bind:status={editStatus}
submitLabel="Update Device"
submitting={deviceVM.updating}
showAdvanced={true}
onsubmit={handleUpdate}
/>
</Card.Content>
</Card.Root>
</div>
<!-- Full-width Live Device Session below -->
<Card.Root class="overflow-hidden">
<Card.Header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon
icon={MonitorSmartphone}
cls="h-4 w-4 text-primary"
/>
<Card.Title class="text-base">
Live Device Session
</Card.Title>
</div>
{#if streamUrl}
<a
href={streamUrl}
target="_blank"
rel="noreferrer"
class={buttonVariants({
variant: "ghost",
size: "sm",
})}
>
<Icon icon={ExternalLink} cls="mr-1.5 h-3.5 w-3.5" />
Pop out
</a>
{/if}
</div>
<Card.Description>
Full interactive device access is embedded here so
admins do not need to leave the dashboard.
</Card.Description>
</Card.Header>
<Card.Content>
{#if streamUrl}
<div
class="bg-muted overflow-hidden rounded-xl border"
>
<iframe
title={`Live stream for ${currentDevice.title}`}
src={streamUrl}
class="h-[75vh] min-h-[480px] w-full bg-black"
allow="autoplay; clipboard-read; clipboard-write"
></iframe>
</div>
{:else}
<div
class="rounded-xl border border-dashed p-10 text-center"
>
<p class="text-sm font-medium">
Stream URL unavailable
</p>
<p class="text-muted-foreground mt-1 text-sm">
Save a valid ws-scrcpy host or host/port for
this device to embed the live session here.
</p>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</Card.Content>
</Card.Root>
</MaxWidthWrapper>

View File

@@ -1,399 +0,0 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { Badge } from "$lib/components/ui/badge";
import { Button, buttonVariants } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import * as Dialog from "$lib/components/ui/dialog";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Switch } from "$lib/components/ui/switch";
import * as Table from "$lib/components/ui/table";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { mainNavTree } from "$lib/core/constants";
import { deviceVM } from "$lib/domains/device/device.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import Monitor from "@lucide/svelte/icons/monitor";
import Plus from "@lucide/svelte/icons/plus";
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
import Trash2 from "@lucide/svelte/icons/trash-2";
import { onMount } from "svelte";
breadcrumbs.set([mainNavTree[0], mainNavTree[2]]);
let title = $state("");
let version = $state("");
let host = $state("");
let containerId = $state("");
let wsPort = $state("");
let isActive = $state(false);
onMount(async () => {
await deviceVM.fetchDevices();
});
function resetForm() {
title = "";
version = "";
host = "";
containerId = "";
wsPort = "";
isActive = false;
}
async function handleCreate(e: Event) {
e.preventDefault();
const success = await deviceVM.createDevice({
title,
version,
host,
containerId: containerId || undefined,
wsPort: wsPort || undefined,
isActive,
});
if (success) resetForm();
}
function statusColor(status: string): string {
switch (status) {
case "online":
return "bg-green-500";
case "offline":
return "bg-zinc-400";
case "busy":
return "bg-amber-500";
case "error":
return "bg-red-500";
default:
return "bg-zinc-400";
}
}
</script>
<MaxWidthWrapper cls="space-y-4">
<Card.Root>
<Card.Header>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex items-center gap-2">
<Icon icon={Monitor} cls="h-5 w-5 text-primary" />
<Card.Title>Devices</Card.Title>
<span class="text-muted-foreground text-xs">
{deviceVM.devices.length} total
</span>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => void deviceVM.fetchDevices()}
disabled={deviceVM.loading}
>
<Icon
icon={RefreshCw}
cls={`h-4 w-4 mr-2 ${deviceVM.loading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<Button
size="sm"
onclick={() => (deviceVM.showCreateDialog = true)}
>
<Icon icon={Plus} cls="h-4 w-4 mr-2" />
Add Device
</Button>
</div>
</div>
</Card.Header>
<Card.Content>
{#if !deviceVM.loading && deviceVM.devices.length === 0}
<div class="py-10 text-center text-sm text-muted-foreground">
No devices registered yet.
</div>
{:else}
<!-- Mobile cards -->
<div class="space-y-3 md:hidden">
{#each deviceVM.devices as device (device.id)}
<div class="rounded-lg border bg-background p-3">
<div
class="flex items-start justify-between gap-3"
>
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{device.title}
</p>
<p
class="text-muted-foreground truncate text-xs"
>
{device.host}
</p>
</div>
<span
class="inline-flex items-center gap-1.5 text-xs capitalize"
>
<span
class={`h-2 w-2 rounded-full ${statusColor(device.status)}`}
></span>
{device.status}
</span>
</div>
<div
class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs"
>
<div>
<p class="text-muted-foreground">Version</p>
<p>{device.version}</p>
</div>
<div>
<p class="text-muted-foreground">Active</p>
<p>{device.isActive ? "Yes" : "No"}</p>
</div>
<div>
<p class="text-muted-foreground">
Container
</p>
<p class="truncate font-mono">
{device.containerId || "—"}
</p>
</div>
<div>
<p class="text-muted-foreground">
WS Port
</p>
<p>{device.wsPort || "—"}</p>
</div>
</div>
<div class="mt-3 flex justify-end">
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={deviceVM.deletingId ===
device.id}
>
<Icon icon={Trash2} cls="h-4 w-4" />
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete device?
</AlertDialog.Title>
<AlertDialog.Description>
This will permanently remove
"{device.title}" and unlink it
from any associated links.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() =>
deviceVM.deleteDevice(
device.id,
)}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</div>
</div>
{/each}
</div>
<!-- Desktop table -->
<div class="hidden md:block">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Title</Table.Head>
<Table.Head>Host</Table.Head>
<Table.Head>Version</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Active</Table.Head>
<Table.Head>Container</Table.Head>
<Table.Head>Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each deviceVM.devices as device (device.id)}
<Table.Row>
<Table.Cell class="font-medium">
{device.title}
</Table.Cell>
<Table.Cell>
<span class="font-mono text-xs">
{device.host}
</span>
{#if device.wsPort}
<span
class="text-muted-foreground text-xs"
>
:{device.wsPort}
</span>
{/if}
</Table.Cell>
<Table.Cell>{device.version}</Table.Cell>
<Table.Cell>
<span
class="inline-flex items-center gap-1.5 text-xs capitalize"
>
<span
class={`h-2 w-2 rounded-full ${statusColor(device.status)}`}
></span>
{device.status}
</span>
</Table.Cell>
<Table.Cell>
{#if device.isActive}
<Badge variant="default"
>Active</Badge
>
{:else}
<Badge variant="secondary"
>Inactive</Badge
>
{/if}
</Table.Cell>
<Table.Cell>
<span
class="inline-block max-w-[120px] truncate font-mono text-xs"
>
{device.containerId || "—"}
</span>
</Table.Cell>
<Table.Cell>
<AlertDialog.Root>
<AlertDialog.Trigger
class={buttonVariants({
variant: "destructive",
size: "sm",
})}
disabled={deviceVM.deletingId ===
device.id}
>
<Icon
icon={Trash2}
cls="h-4 w-4"
/>
Delete
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Delete device?
</AlertDialog.Title>
<AlertDialog.Description>
This will permanently
remove "{device.title}"
and unlink it from any
associated links.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={() =>
deviceVM.deleteDevice(
device.id,
)}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</MaxWidthWrapper>
<!-- Create Device Dialog -->
<Dialog.Root bind:open={deviceVM.showCreateDialog}>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Add Device</Dialog.Title>
<Dialog.Description>
Register a new Docker-Android instance.
</Dialog.Description>
</Dialog.Header>
<form onsubmit={handleCreate} class="space-y-4">
<div class="space-y-2">
<Label for="title">Title</Label>
<Input
id="title"
placeholder="e.g. Android 14 — Slot 1"
bind:value={title}
required
/>
</div>
<div class="space-y-2">
<Label for="host">Host</Label>
<Input
id="host"
placeholder="e.g. 192.168.1.10"
bind:value={host}
required
/>
</div>
<div class="space-y-2">
<Label for="version">Version</Label>
<Input
id="version"
placeholder="e.g. android-14"
bind:value={version}
required
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="containerId">Container ID</Label>
<Input
id="containerId"
placeholder="Optional"
bind:value={containerId}
/>
</div>
<div class="space-y-2">
<Label for="wsPort">WS Port</Label>
<Input
id="wsPort"
placeholder="e.g. 8886"
bind:value={wsPort}
/>
</div>
</div>
<div class="flex items-center gap-2">
<Switch id="isActive" bind:checked={isActive} />
<Label for="isActive">Active</Label>
</div>
<Dialog.Footer>
<Button
variant="outline"
type="button"
onclick={() => (deviceVM.showCreateDialog = false)}
>
Cancel
</Button>
<Button type="submit" disabled={deviceVM.creating}>
{deviceVM.creating ? "Creating..." : "Create"}
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -1 +0,0 @@
<span>device id page</span>

View File

@@ -9,7 +9,7 @@
import { Label } from "$lib/components/ui/label";
import * as Table from "$lib/components/ui/table";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { mainNavTree } from "$lib/core/constants";
import { mainNavTree, SUPPORTED_APPS } from "$lib/core/constants";
import { linkVM } from "$lib/domains/link/link.vm.svelte";
import { breadcrumbs } from "$lib/global.stores";
import LinkIcon from "@lucide/svelte/icons/link";
@@ -24,6 +24,7 @@
breadcrumbs.set([mainNavTree[0], mainNavTree[1]]);
let selectedDeviceId = $state("");
let selectedAppPackage = $state("");
let expiresAt = $state("");
onMount(async () => {
@@ -33,15 +34,25 @@
function resetForm() {
selectedDeviceId = "";
selectedAppPackage = "";
expiresAt = "";
}
function getSupportedApp(packageName: string) {
return SUPPORTED_APPS.find((app) => app.packageName === packageName);
}
async function handleCreate(e: Event) {
e.preventDefault();
const selectedApp = getSupportedApp(selectedAppPackage);
if (!selectedApp) {
toast.error("Select a supported app");
return;
}
const success = await linkVM.createLink({
linkedDeviceId: selectedDeviceId
? Number(selectedDeviceId)
: null,
linkedDeviceId: Number(selectedDeviceId),
appName: selectedApp.title,
appPackage: selectedApp.packageName,
expiresAt: expiresAt ? new Date(expiresAt) : null,
});
if (success) resetForm();
@@ -163,6 +174,15 @@
)}
</p>
</div>
<div>
<p class="text-muted-foreground">App</p>
<p>{link.appName}</p>
<p
class="text-muted-foreground break-all font-mono text-[11px]"
>
{link.appPackage}
</p>
</div>
<div>
<p class="text-muted-foreground">Expires</p>
<p>{formatDate(link.expiresAt)}</p>
@@ -238,6 +258,7 @@
<Table.Head>Token</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Device</Table.Head>
<Table.Head>Leased App</Table.Head>
<Table.Head>Expires</Table.Head>
<Table.Head>Last Accessed</Table.Head>
<Table.Head>Created</Table.Head>
@@ -277,6 +298,18 @@
link.linkedDeviceId,
)}
</Table.Cell>
<Table.Cell>
<div class="space-y-0.5">
<p class="text-sm font-medium">
{link.appName}
</p>
<p
class="text-muted-foreground font-mono text-[11px]"
>
{link.appPackage}
</p>
</div>
</Table.Cell>
<Table.Cell class="text-xs">
{formatDate(link.expiresAt)}
</Table.Cell>
@@ -373,21 +406,44 @@
</Dialog.Header>
<form onsubmit={handleCreate} class="space-y-4">
<div class="space-y-2">
<Label for="device">Assign Device</Label>
<Label for="device">Assigned Device</Label>
<select
id="device"
bind:value={selectedDeviceId}
required
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1"
>
<option value="">No device</option>
<option value="" disabled>Select a device</option>
{#each linkVM.availableDevices as device}
<option value={String(device.id)}>
{device.title}{device.host}
{device.title}{device.host}{device.inUse
? " (in use)"
: ""}
</option>
{/each}
</select>
<p class="text-muted-foreground text-xs">
Optional. You can assign a device later.
Links now target one specific device.
</p>
</div>
<div class="space-y-2">
<Label for="supportedApp">Supported App</Label>
<select
id="supportedApp"
bind:value={selectedAppPackage}
required
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1"
>
<option value="" disabled>Select a supported app</option>
{#each SUPPORTED_APPS as app}
<option value={app.packageName}>
{app.title}{app.packageName}
</option>
{/each}
</select>
<p class="text-muted-foreground text-xs">
Links can only target apps from the current supported app
list.
</p>
</div>
<div class="space-y-2">

View File

@@ -0,0 +1,27 @@
FROM node:25.6.1 AS production
RUN npm i -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/front/package.json ./apps/front/package.json
COPY packages ./packages
RUN pnpm install
COPY apps/front ./apps/front
RUN pnpm install
RUN pnpm run build
COPY scripts/prod.start.sh ./scripts/prod.start.sh
EXPOSE 3000
RUN chmod +x scripts/prod.start.sh
CMD ["/bin/sh", "scripts/prod.start.sh", "apps/front"]

View File

@@ -18,7 +18,7 @@ RUN pnpm install
RUN pnpm run build
COPY scripts ./scripts
COPY scripts/prod.start.sh ./scripts/prod.start.sh
EXPOSE 3000

View File

@@ -0,0 +1,27 @@
FROM node:25.6.1 AS production
RUN npm i -g pnpm
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/orchestrator/package.json ./apps/orchestrator/package.json
COPY packages ./packages
RUN pnpm install
COPY apps/orchestrator ./apps/orchestrator
RUN pnpm install
RUN pnpm run build
COPY scripts/prod.start.sh ./scripts/prod.start.sh
EXPOSE 3000
RUN chmod +x scripts/prod.start.sh
CMD ["/bin/sh", "scripts/prod.start.sh", "apps/orchestrator"]

View File

@@ -11,7 +11,7 @@ Update rule:
---
### 0 (genesis)
### 0 - Genesis
- init
@@ -30,7 +30,7 @@ Update rule:
- Device CRUD page at /devices — list table, create dialog, delete with confirmation, status dots, refresh
- Link CRUD page at /links — list table, generate link dialog (device select + expiry), revoke, delete, copy token
- Remote functions for both domains (*SQ queries, *SC commands)
- View models (*.vm.svelte.ts) with optimistic UI updates (create appends, delete filters locally)
- View models (\*.vm.svelte.ts) with optimistic UI updates (create appends, delete filters locally)
- Fixed bug: table not refreshing after create (was SvelteKit query cache — switched to optimistic local state)
- Fixed bug: /links auth failure (hooks.server.ts path check matching "/link" inside "/links")
@@ -42,3 +42,51 @@ Update rule:
- Deployed via Dokploy with Traefik domain routing
- Networking: redroid on bridge, ws-scrcpy on dokploy overlay — connected via host bridge gateway (172.17.0.1:5555)
- ws-scrcpy live and streaming redroid at iotam-ws-scrcpy.snapyra.com
### 4 — Device Detail Page
- Implemented `/devices/[id]` admin detail page with metadata, refresh/open actions, and an embedded live ws-scrcpy iframe
- Added a dedicated device details VM and derived the viewer URL from stored device host/wsPort data
- Updated `/devices` so device entries navigate into their detail page
### 5 — README Product Flow Update
- Updated the implementation plan to reflect the app-leasing model: links target a specific app on a device, front triggers orchestrator reset/launch during loading, and devices need explicit `inUse` tracking
### 6 — Admin App Leasing Model
- Added `inUse` to the device schema/domain and surfaced it in the devices admin list/detail views
- Extended links to store required leased app data (`appName`, `appPackage`) alongside the assigned device
- Updated the admin links creation flow and listing UI so links are created against a specific device and app package
### 7 — Required Device Connection Fields
- Propagated `containerId` and `wsPort` as required device fields across validation, repository create logic, and the admin device creation form to match the non-null schema
### 8 — Reused Device Edit Form
- Added a shared device form component under the device domain and reused it for create/edit flows
- Added manual device editing in the devices list and device detail page, including overrides for host, container ID, ws port, status, `isActive`, and `inUse`
### 9 — Supported App Link Creation
- Updated admin link creation to select from `SUPPORTED_APPS` in constants instead of accepting freeform app name/package input
### 10 — Device Detail Page Layout Improvement
- Restructured device detail layout: Metadata + Edit Device side-by-side in top row, Live Device Session full-width below
- Iframe now uses `h-[75vh] min-h-[480px]` instead of fixed 720px height, making it fill available space
- Added "Pop out" link in the Live Device Session card header for quick external access
- Tightened metadata card with inline grid rows (port/in-use, created/updated) and smaller labels
- Styled backtick code refs in Edit Device description with `<code>` tags
- Updated skeleton loading state to match the new two-row layout
### 11 — Merged Devices Page into Dashboard
- Moved device list (table, create/edit/delete dialogs) from `/devices` into `/dashboard` — dashboard is now the devices home
- Moved device detail page from `/devices/[id]` to `/dashboard/[id]`
- Removed `/devices` route directory entirely
- Removed "Devices" entry from `mainNavTree` sidebar navigation (was index 2)
- Cleaned up unused `Smartphone` import from constants.ts
- Updated detail page breadcrumbs to go Dashboard > Device instead of Dashboard > Devices > Device
- Updated `goto` calls to navigate to `/dashboard/:id` instead of `/devices/:id`

View File

@@ -0,0 +1,3 @@
ALTER TABLE "device" ADD COLUMN "in_use" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "link" ADD COLUMN "app_name" text NOT NULL;--> statement-breakpoint
ALTER TABLE "link" ADD COLUMN "app_package" text NOT NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "device" ALTER COLUMN "container_id" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "device" ALTER COLUMN "ws_port" SET NOT NULL;

View File

@@ -0,0 +1,939 @@
{
"id": "b3c84126-2831-4271-a12a-457f0dcc8c46",
"prevId": "c0dc4466-4211-49aa-97b0-917cc0c30871",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.two_factor": {
"name": "two_factor",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"secret": {
"name": "secret",
"type": "text",
"primaryKey": false,
"notNull": true
},
"backup_codes": {
"name": "backup_codes",
"type": "json",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"two_factor_user_id_user_id_fk": {
"name": "two_factor_user_id_user_id_fk",
"tableFrom": "two_factor",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.twofa_sessions": {
"name": "twofa_sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"verification_token": {
"name": "verification_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"code_used": {
"name": "code_used",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"max_attempts": {
"name": "max_attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 5
},
"verified_at": {
"name": "verified_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "''"
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "''"
}
},
"indexes": {},
"foreignKeys": {
"twofa_sessions_user_id_user_id_fk": {
"name": "twofa_sessions_user_id_user_id_fk",
"tableFrom": "twofa_sessions",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"twofa_sessions_verification_token_unique": {
"name": "twofa_sessions_verification_token_unique",
"nullsNotDistinct": false,
"columns": [
"verification_token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_username": {
"name": "display_username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"onboarding_done": {
"name": "onboarding_done",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"last2_fa_verified_at": {
"name": "last2_fa_verified_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
},
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.device": {
"name": "device",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'offline'"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"in_use": {
"name": "in_use",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"container_id": {
"name": "container_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"host": {
"name": "host",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ws_port": {
"name": "ws_port",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notifications": {
"name": "notifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"priority": {
"name": "priority",
"type": "varchar(12)",
"primaryKey": false,
"notNull": true,
"default": "'normal'"
},
"type": {
"name": "type",
"type": "varchar(12)",
"primaryKey": false,
"notNull": true
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"is_archived": {
"name": "is_archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"action_url": {
"name": "action_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"action_type": {
"name": "action_type",
"type": "varchar(16)",
"primaryKey": false,
"notNull": false
},
"action_data": {
"name": "action_data",
"type": "json",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sent_at": {
"name": "sent_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"read_at": {
"name": "read_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"notifications_user_id_user_id_fk": {
"name": "notifications_user_id_user_id_fk",
"tableFrom": "notifications",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.link": {
"name": "link",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'active'"
},
"app_name": {
"name": "app_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"app_package": {
"name": "app_package",
"type": "text",
"primaryKey": false,
"notNull": true
},
"linked_device_id": {
"name": "linked_device_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_accessed_at": {
"name": "last_accessed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"link_linked_device_id_device_id_fk": {
"name": "link_linked_device_id_device_id_fk",
"tableFrom": "link",
"tableTo": "device",
"columnsFrom": [
"linked_device_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"link_token_unique": {
"name": "link_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.task": {
"name": "task",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"payload": {
"name": "payload",
"type": "json",
"primaryKey": false,
"notNull": false
},
"result": {
"name": "result",
"type": "json",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "json",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"task_user_id_user_id_fk": {
"name": "task_user_id_user_id_fk",
"tableFrom": "task",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,939 @@
{
"id": "a633b0b6-32a7-4f7f-8b17-4264fe54ca57",
"prevId": "b3c84126-2831-4271-a12a-457f0dcc8c46",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.two_factor": {
"name": "two_factor",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"secret": {
"name": "secret",
"type": "text",
"primaryKey": false,
"notNull": true
},
"backup_codes": {
"name": "backup_codes",
"type": "json",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"two_factor_user_id_user_id_fk": {
"name": "two_factor_user_id_user_id_fk",
"tableFrom": "two_factor",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.twofa_sessions": {
"name": "twofa_sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"verification_token": {
"name": "verification_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"code_used": {
"name": "code_used",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"max_attempts": {
"name": "max_attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 5
},
"verified_at": {
"name": "verified_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "''"
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "''"
}
},
"indexes": {},
"foreignKeys": {
"twofa_sessions_user_id_user_id_fk": {
"name": "twofa_sessions_user_id_user_id_fk",
"tableFrom": "twofa_sessions",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"twofa_sessions_verification_token_unique": {
"name": "twofa_sessions_verification_token_unique",
"nullsNotDistinct": false,
"columns": [
"verification_token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_username": {
"name": "display_username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"onboarding_done": {
"name": "onboarding_done",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"last2_fa_verified_at": {
"name": "last2_fa_verified_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
},
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.device": {
"name": "device",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'offline'"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"in_use": {
"name": "in_use",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"container_id": {
"name": "container_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"host": {
"name": "host",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ws_port": {
"name": "ws_port",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notifications": {
"name": "notifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"priority": {
"name": "priority",
"type": "varchar(12)",
"primaryKey": false,
"notNull": true,
"default": "'normal'"
},
"type": {
"name": "type",
"type": "varchar(12)",
"primaryKey": false,
"notNull": true
},
"category": {
"name": "category",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"is_archived": {
"name": "is_archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"action_url": {
"name": "action_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"action_type": {
"name": "action_type",
"type": "varchar(16)",
"primaryKey": false,
"notNull": false
},
"action_data": {
"name": "action_data",
"type": "json",
"primaryKey": false,
"notNull": false
},
"icon": {
"name": "icon",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sent_at": {
"name": "sent_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"read_at": {
"name": "read_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"notifications_user_id_user_id_fk": {
"name": "notifications_user_id_user_id_fk",
"tableFrom": "notifications",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.link": {
"name": "link",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"default": "'active'"
},
"app_name": {
"name": "app_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"app_package": {
"name": "app_package",
"type": "text",
"primaryKey": false,
"notNull": true
},
"linked_device_id": {
"name": "linked_device_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_accessed_at": {
"name": "last_accessed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"link_linked_device_id_device_id_fk": {
"name": "link_linked_device_id_device_id_fk",
"tableFrom": "link",
"tableTo": "device",
"columnsFrom": [
"linked_device_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"link_token_unique": {
"name": "link_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.task": {
"name": "task",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"type": {
"name": "type",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"payload": {
"name": "payload",
"type": "json",
"primaryKey": false,
"notNull": false
},
"result": {
"name": "result",
"type": "json",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "json",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"resource_id": {
"name": "resource_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"task_user_id_user_id_fk": {
"name": "task_user_id_user_id_fk",
"tableFrom": "task",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,20 @@
"when": 1774650657798,
"tag": "0000_colorful_the_leader",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1774703256575,
"tag": "0001_mysterious_thor_girl",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1774703478082,
"tag": "0002_remarkable_charles_xavier",
"breakpoints": true
}
]
}

View File

@@ -15,10 +15,11 @@ export const device = pgTable("device", {
status: varchar("status", { length: 16 }).notNull().default("offline"), // "online" | "offline" | "busy" | "error"
isActive: boolean("is_active").notNull().default(false),
inUse: boolean("in_use").notNull().default(false),
containerId: text("container_id"), // Docker container ID on the VPS
containerId: text("container_id").notNull(), // Docker container ID on the VPS
host: text("host").notNull(), // VPS hostname or IP
wsPort: text("ws_port"), // ws-scrcpy WebSocket port
wsPort: text("ws_port").notNull(), // ws-scrcpy WebSocket port
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),

View File

@@ -1,4 +1,11 @@
import { integer, pgTable, serial, text, timestamp, varchar } from "drizzle-orm/pg-core";
import {
integer,
pgTable,
serial,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { device } from "./device.schema";
import { relations } from "drizzle-orm";
@@ -7,6 +14,8 @@ export const link = pgTable("link", {
token: text("token").notNull().unique(),
status: varchar("status", { length: 16 }).notNull().default("active"), // "active" | "inactive" | "expired" | "revoked"
appName: text("app_name").notNull(),
appPackage: text("app_package").notNull(),
linkedDeviceId: integer("linked_device_id").references(() => device.id, {
onDelete: "set null",

View File

@@ -47,10 +47,13 @@ export class DeviceController {
*/
allocate(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
return this.repo.getById(fctx, id).andThen((dev) => {
if (dev.status !== DeviceStatus.ONLINE) {
if (dev.status !== DeviceStatus.ONLINE || dev.inUse) {
return errAsync(deviceErrors.deviceNotAvailable(fctx, id));
}
return this.repo.setStatus(fctx, id, DeviceStatus.BUSY);
return this.repo.update(fctx, id, {
status: DeviceStatus.BUSY,
inUse: true,
});
});
}
@@ -58,7 +61,10 @@ export class DeviceController {
* Release a device back to online after a session ends.
*/
release(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
return this.repo.setStatus(fctx, id, DeviceStatus.ONLINE);
return this.repo.update(fctx, id, {
status: DeviceStatus.ONLINE,
inUse: false,
});
}
}

View File

@@ -16,9 +16,10 @@ export const deviceSchema = v.object({
version: v.string(),
status: deviceStatusSchema,
isActive: v.boolean(),
containerId: v.nullable(v.string()),
inUse: v.boolean(),
containerId: v.string(),
host: v.string(),
wsPort: v.nullable(v.string()),
wsPort: v.string(),
createdAt: v.date(),
updatedAt: v.date(),
});
@@ -28,8 +29,8 @@ export const createDeviceSchema = v.object({
title: v.pipe(v.string(), v.minLength(1)),
version: v.pipe(v.string(), v.minLength(1)),
host: v.pipe(v.string(), v.minLength(1)),
containerId: v.optional(v.string()),
wsPort: v.optional(v.string()),
containerId: v.pipe(v.string(), v.minLength(1)),
wsPort: v.pipe(v.string(), v.minLength(1)),
isActive: v.optional(v.boolean()),
});
export type CreateDevice = v.InferOutput<typeof createDeviceSchema>;
@@ -39,9 +40,10 @@ export const updateDeviceSchema = v.partial(
title: v.string(),
version: v.string(),
host: v.string(),
containerId: v.nullable(v.string()),
wsPort: v.nullable(v.string()),
containerId: v.string(),
wsPort: v.string(),
isActive: v.boolean(),
inUse: v.boolean(),
status: deviceStatusSchema,
}),
);

View File

@@ -60,10 +60,11 @@ export class DeviceRepository {
title: data.title,
version: data.version,
host: data.host,
containerId: data.containerId ?? null,
wsPort: data.wsPort ?? null,
containerId: data.containerId,
wsPort: data.wsPort,
status: DeviceStatus.OFFLINE,
isActive: data.isActive ?? false,
inUse: false,
createdAt: new Date(),
updatedAt: new Date(),
})

View File

@@ -15,6 +15,8 @@ export const linkSchema = v.object({
id: v.number(),
token: v.string(),
status: linkStatusSchema,
appName: v.string(),
appPackage: v.string(),
linkedDeviceId: v.nullable(v.number()),
expiresAt: v.nullable(v.date()),
lastAccessedAt: v.nullable(v.date()),
@@ -31,7 +33,9 @@ export type LinkWithDevice = v.InferOutput<typeof linkWithDeviceSchema>;
export const createLinkSchema = v.object({
token: v.pipe(v.string(), v.minLength(1)),
linkedDeviceId: v.optional(v.nullable(v.number())),
appName: v.pipe(v.string(), v.minLength(1)),
appPackage: v.pipe(v.string(), v.minLength(1)),
linkedDeviceId: v.number(),
expiresAt: v.optional(v.nullable(v.date())),
});
export type CreateLink = v.InferOutput<typeof createLinkSchema>;
@@ -39,6 +43,8 @@ export type CreateLink = v.InferOutput<typeof createLinkSchema>;
export const updateLinkSchema = v.partial(
v.object({
status: linkStatusSchema,
appName: v.string(),
appPackage: v.string(),
linkedDeviceId: v.nullable(v.number()),
expiresAt: v.nullable(v.date()),
lastAccessedAt: v.nullable(v.date()),

View File

@@ -81,6 +81,8 @@ export class LinkRepository {
.values({
token: data.token,
status: "active",
appName: data.appName,
appPackage: data.appPackage,
linkedDeviceId: data.linkedDeviceId ?? null,
expiresAt: data.expiresAt ?? null,
createdAt: new Date(),

View File

@@ -18,7 +18,8 @@ export const settingsSchema = v.object({
internalApiKey: v.string(),
debugKey: v.string(),
processorApiUrl: v.string(),
orchestratorApiUrl: v.string(),
wsScrcpySvcUrl: v.string(),
betterAuthUrl: v.string(),
betterAuthSecret: v.string(),
@@ -32,19 +33,6 @@ export const settingsSchema = v.object({
otelServiceName: v.string(),
otelExporterOtlpHttpEndpoint: v.string(),
// R2/Object Storage settings
r2BucketName: v.string(),
r2Region: v.string(),
r2Endpoint: v.string(),
r2AccessKey: v.string(),
r2SecretKey: v.string(),
r2PublicUrl: v.optional(v.string()),
// File upload settings
maxFileSize: v.number(),
allowedMimeTypes: v.array(v.string()),
allowedExtensions: v.array(v.string()),
});
export type Settings = v.InferOutput<typeof settingsSchema>;
@@ -95,15 +83,11 @@ function loadSettings(): Settings {
internalApiKey: getEnv("INTERNAL_API_KEY"),
debugKey: getEnv("DEBUG_KEY"),
processorApiUrl: getEnv("PROCESSOR_API_URL", "http://localhost:3000"),
appBuilderApiUrl: getEnv(
"APP_BUILDER_API_URL",
"http://localhost:3001",
),
appBuilderAssetsPublicUrl: getEnv(
"APP_BUILDER_ASSETS_PUBLIC_URL",
"http://localhost:3001",
orchestratorApiUrl: getEnv(
"ORCHESTRATOR_API_URL",
"http://localhost:3000",
),
wsScrcpySvcUrl: getEnv("WS_SCRCPY_SVC_URL"),
betterAuthUrl: getEnv("BETTER_AUTH_URL"),
betterAuthSecret: getEnv("BETTER_AUTH_SECRET"),
@@ -122,26 +106,6 @@ function loadSettings(): Settings {
otelExporterOtlpHttpEndpoint: getEnv(
"OTEL_EXPORTER_OTLP_HTTP_ENDPOINT",
),
// R2/Object Storage settings
r2BucketName: getEnv("R2_BUCKET_NAME"),
r2Region: getEnv("R2_REGION", "auto"),
r2Endpoint: getEnv("R2_ENDPOINT"),
r2AccessKey: getEnv("R2_ACCESS_KEY"),
r2SecretKey: getEnv("R2_SECRET_KEY"),
r2PublicUrl: getEnv("R2_PUBLIC_URL") || undefined,
// File upload settings
maxFileSize: getEnvNumber("MAX_FILE_SIZE", 10485760), // 10MB default
allowedMimeTypes: parseCommaSeparated(
getEnv(
"ALLOWED_MIME_TYPES",
"image/jpeg,image/png,image/webp,image/gif,application/pdf,text/plain",
),
),
allowedExtensions: parseCommaSeparated(
getEnv("ALLOWED_EXTENSIONS", "jpg,jpeg,png,webp,gif,pdf,txt"),
),
};
try {