major updates to device and links management in admin
This commit is contained in:
@@ -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.
|
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
|
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.
|
More rules are only to be added by the human, in case such a suggestion becomes viable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -8,12 +8,13 @@ Currently in alpha. Greenfield. Subject to change.
|
|||||||
|
|
||||||
## How It Works
|
## 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`.
|
2. User opens that link in their browser — served by `apps/front`.
|
||||||
3. User is shown a loading screen for good UX purposes
|
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. User is prompted to install the PWA.
|
4. If that device is already in use by another end user, the link fails instead of taking over the session.
|
||||||
5. User opens the PWA — they are routed into a live stream of their assigned Android instance.
|
5. User is prompted to install the PWA.
|
||||||
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.
|
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 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
|
- [ ] Device domain in `@pkg/logic` — controller + repository + errors
|
||||||
- [ ] Orchestrator command interface — secured Hono routes the admin dashboard calls:
|
- [ ] Orchestrator command interface — secured Hono routes the admin dashboard calls:
|
||||||
- [ ] `POST /devices/:id/start` — start a Docker-Android container
|
- [ ] `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
|
- [ ] `POST /devices/:id/restart` — restart a container
|
||||||
- [ ] `GET /devices` — list all devices and their current status
|
- [ ] `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)
|
- [ ] `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 allocation logic — atomically mark a device as `inUse` when a validated link starts a session
|
||||||
- [ ] Device release logic — free up a device when a session ends
|
- [ ] 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
|
- [ ] Admin dashboard: Devices page — list fleet, show status, trigger start/stop/restart
|
||||||
- [ ] Internal API key auth between `apps/main` and `apps/orchestrator`
|
- [ ] Internal API key auth between `apps/main` and `apps/orchestrator`
|
||||||
|
|
||||||
### Link Management (Admin + Front App)
|
### 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
|
- [ ] 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`: 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
|
- [ ] `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
|
- [ ] Front: keep on checking for link status change, if it gets revoked, we cutoff the connection
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@apps/front",
|
"name": "@apps/front",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=3000 tsx watch src/index.ts",
|
"dev": "PORT=3001 tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"prod": "HOST=0.0.0.0 PORT=3000 tsx src/index.ts"
|
"prod": "HOST=0.0.0.0 PORT=3000 tsx src/index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
|
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
|
||||||
import Smartphone from "@lucide/svelte/icons/smartphone";
|
|
||||||
import { BellRingIcon, Link } from "@lucide/svelte";
|
import { BellRingIcon, Link } from "@lucide/svelte";
|
||||||
import UserCircle from "~icons/lucide/user-circle";
|
import UserCircle from "~icons/lucide/user-circle";
|
||||||
|
|
||||||
@@ -26,11 +25,6 @@ export const mainNavTree = [
|
|||||||
url: "/links",
|
url: "/links",
|
||||||
icon: Link,
|
icon: Link,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Devices",
|
|
||||||
url: "/devices",
|
|
||||||
icon: Smartphone,
|
|
||||||
},
|
|
||||||
] as AppSidebarItem[];
|
] as AppSidebarItem[];
|
||||||
|
|
||||||
export const secondaryNavTree = [
|
export const secondaryNavTree = [
|
||||||
@@ -46,6 +40,18 @@ export const secondaryNavTree = [
|
|||||||
},
|
},
|
||||||
] as AppSidebarItem[];
|
] 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 COMPANY_NAME = "SaaS Template";
|
||||||
export const WEBSITE_URL = "https://company.com";
|
export const WEBSITE_URL = "https://company.com";
|
||||||
|
|
||||||
|
|||||||
80
apps/main/src/lib/domains/device/device-details.vm.svelte.ts
Normal file
80
apps/main/src/lib/domains/device/device-details.vm.svelte.ts
Normal 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();
|
||||||
134
apps/main/src/lib/domains/device/device-form.svelte
Normal file
134
apps/main/src/lib/domains/device/device-form.svelte
Normal 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>
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
createDeviceSC,
|
createDeviceSC,
|
||||||
deleteDeviceSC,
|
deleteDeviceSC,
|
||||||
setDeviceStatusSC,
|
setDeviceStatusSC,
|
||||||
|
updateDeviceSC,
|
||||||
} from "./device.remote";
|
} from "./device.remote";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
@@ -12,9 +13,10 @@ type Device = {
|
|||||||
version: string;
|
version: string;
|
||||||
status: string;
|
status: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
containerId: string | null;
|
inUse: boolean;
|
||||||
|
containerId: string;
|
||||||
host: string;
|
host: string;
|
||||||
wsPort: string | null;
|
wsPort: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
@@ -23,17 +25,24 @@ type CreateDeviceInput = {
|
|||||||
title: string;
|
title: string;
|
||||||
version: string;
|
version: string;
|
||||||
host: string;
|
host: string;
|
||||||
containerId?: string;
|
containerId: string;
|
||||||
wsPort?: string;
|
wsPort: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UpdateDeviceInput = CreateDeviceInput & {
|
||||||
|
status: "online" | "offline" | "busy" | "error";
|
||||||
|
inUse: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
class DeviceViewModel {
|
class DeviceViewModel {
|
||||||
devices = $state<Device[]>([]);
|
devices = $state<Device[]>([]);
|
||||||
loading = $state(false);
|
loading = $state(false);
|
||||||
creating = $state(false);
|
creating = $state(false);
|
||||||
|
updating = $state(false);
|
||||||
deletingId = $state<number | null>(null);
|
deletingId = $state<number | null>(null);
|
||||||
showCreateDialog = $state(false);
|
showCreateDialog = $state(false);
|
||||||
|
editingId = $state<number | null>(null);
|
||||||
|
|
||||||
async fetchDevices() {
|
async fetchDevices() {
|
||||||
this.loading = true;
|
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) {
|
async setStatus(id: number, status: string) {
|
||||||
try {
|
try {
|
||||||
const result = await setDeviceStatusSC({
|
const result = await setDeviceStatusSC({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getLinkController } from "@pkg/logic/domains/link/controller";
|
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 { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils";
|
||||||
import { command, getRequestEvent, query } from "$app/server";
|
import { command, getRequestEvent, query } from "$app/server";
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
@@ -32,10 +32,7 @@ export const getLinkByIdSQ = query(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const createLinkSC = command(
|
export const createLinkSC = command(
|
||||||
v.object({
|
v.omit(createLinkSchema, ["token"]),
|
||||||
linkedDeviceId: v.optional(v.nullable(v.number())),
|
|
||||||
expiresAt: v.optional(v.nullable(v.date())),
|
|
||||||
}),
|
|
||||||
async (payload) => {
|
async (payload) => {
|
||||||
const event = getRequestEvent();
|
const event = getRequestEvent();
|
||||||
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type Link = {
|
|||||||
id: number;
|
id: number;
|
||||||
token: string;
|
token: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
appName: string;
|
||||||
|
appPackage: string;
|
||||||
linkedDeviceId: number | null;
|
linkedDeviceId: number | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
lastAccessedAt: Date | null;
|
lastAccessedAt: Date | null;
|
||||||
@@ -24,6 +26,7 @@ type DeviceOption = {
|
|||||||
title: string;
|
title: string;
|
||||||
host: string;
|
host: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
inUse: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
class LinkViewModel {
|
class LinkViewModel {
|
||||||
@@ -71,6 +74,7 @@ class LinkViewModel {
|
|||||||
title: d.title,
|
title: d.title,
|
||||||
host: d.host,
|
host: d.host,
|
||||||
status: d.status,
|
status: d.status,
|
||||||
|
inUse: d.inUse,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -79,7 +83,9 @@ class LinkViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createLink(data: {
|
async createLink(data: {
|
||||||
linkedDeviceId?: number | null;
|
linkedDeviceId: number;
|
||||||
|
appName: string;
|
||||||
|
appPackage: string;
|
||||||
expiresAt?: Date | null;
|
expiresAt?: Date | null;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
this.creating = true;
|
this.creating = true;
|
||||||
|
|||||||
@@ -1,11 +1,511 @@
|
|||||||
<script lang="ts">
|
<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 MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||||
import { mainNavTree } from "$lib/core/constants";
|
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 { 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]]);
|
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>
|
</script>
|
||||||
|
|
||||||
<MaxWidthWrapper cls="space-y-4">
|
<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>
|
</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>
|
||||||
|
|||||||
388
apps/main/src/routes/(main)/dashboard/[id]/+page.svelte
Normal file
388
apps/main/src/routes/(main)/dashboard/[id]/+page.svelte
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<span>device id page</span>
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from "$lib/components/ui/label";
|
||||||
import * as Table from "$lib/components/ui/table";
|
import * as Table from "$lib/components/ui/table";
|
||||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
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 { linkVM } from "$lib/domains/link/link.vm.svelte";
|
||||||
import { breadcrumbs } from "$lib/global.stores";
|
import { breadcrumbs } from "$lib/global.stores";
|
||||||
import LinkIcon from "@lucide/svelte/icons/link";
|
import LinkIcon from "@lucide/svelte/icons/link";
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
breadcrumbs.set([mainNavTree[0], mainNavTree[1]]);
|
breadcrumbs.set([mainNavTree[0], mainNavTree[1]]);
|
||||||
|
|
||||||
let selectedDeviceId = $state("");
|
let selectedDeviceId = $state("");
|
||||||
|
let selectedAppPackage = $state("");
|
||||||
let expiresAt = $state("");
|
let expiresAt = $state("");
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -33,15 +34,25 @@
|
|||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
selectedDeviceId = "";
|
selectedDeviceId = "";
|
||||||
|
selectedAppPackage = "";
|
||||||
expiresAt = "";
|
expiresAt = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSupportedApp(packageName: string) {
|
||||||
|
return SUPPORTED_APPS.find((app) => app.packageName === packageName);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreate(e: Event) {
|
async function handleCreate(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const selectedApp = getSupportedApp(selectedAppPackage);
|
||||||
|
if (!selectedApp) {
|
||||||
|
toast.error("Select a supported app");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const success = await linkVM.createLink({
|
const success = await linkVM.createLink({
|
||||||
linkedDeviceId: selectedDeviceId
|
linkedDeviceId: Number(selectedDeviceId),
|
||||||
? Number(selectedDeviceId)
|
appName: selectedApp.title,
|
||||||
: null,
|
appPackage: selectedApp.packageName,
|
||||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||||
});
|
});
|
||||||
if (success) resetForm();
|
if (success) resetForm();
|
||||||
@@ -163,6 +174,15 @@
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<p class="text-muted-foreground">Expires</p>
|
<p class="text-muted-foreground">Expires</p>
|
||||||
<p>{formatDate(link.expiresAt)}</p>
|
<p>{formatDate(link.expiresAt)}</p>
|
||||||
@@ -238,6 +258,7 @@
|
|||||||
<Table.Head>Token</Table.Head>
|
<Table.Head>Token</Table.Head>
|
||||||
<Table.Head>Status</Table.Head>
|
<Table.Head>Status</Table.Head>
|
||||||
<Table.Head>Device</Table.Head>
|
<Table.Head>Device</Table.Head>
|
||||||
|
<Table.Head>Leased App</Table.Head>
|
||||||
<Table.Head>Expires</Table.Head>
|
<Table.Head>Expires</Table.Head>
|
||||||
<Table.Head>Last Accessed</Table.Head>
|
<Table.Head>Last Accessed</Table.Head>
|
||||||
<Table.Head>Created</Table.Head>
|
<Table.Head>Created</Table.Head>
|
||||||
@@ -277,6 +298,18 @@
|
|||||||
link.linkedDeviceId,
|
link.linkedDeviceId,
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</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">
|
<Table.Cell class="text-xs">
|
||||||
{formatDate(link.expiresAt)}
|
{formatDate(link.expiresAt)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -373,21 +406,44 @@
|
|||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
<form onsubmit={handleCreate} class="space-y-4">
|
<form onsubmit={handleCreate} class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="device">Assign Device</Label>
|
<Label for="device">Assigned Device</Label>
|
||||||
<select
|
<select
|
||||||
id="device"
|
id="device"
|
||||||
bind:value={selectedDeviceId}
|
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"
|
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}
|
{#each linkVM.availableDevices as device}
|
||||||
<option value={String(device.id)}>
|
<option value={String(device.id)}>
|
||||||
{device.title} — {device.host}
|
{device.title} — {device.host}{device.inUse
|
||||||
|
? " (in use)"
|
||||||
|
: ""}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
<p class="text-muted-foreground text-xs">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
|||||||
27
dockerfiles/front.Dockerfile
Normal file
27
dockerfiles/front.Dockerfile
Normal 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"]
|
||||||
@@ -18,7 +18,7 @@ RUN pnpm install
|
|||||||
|
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
COPY scripts ./scripts
|
COPY scripts/prod.start.sh ./scripts/prod.start.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
27
dockerfiles/orchestrator.Dockerfile
Normal file
27
dockerfiles/orchestrator.Dockerfile
Normal 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"]
|
||||||
@@ -11,7 +11,7 @@ Update rule:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 0 (genesis)
|
### 0 - Genesis
|
||||||
|
|
||||||
- init
|
- init
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ Update rule:
|
|||||||
- Device CRUD page at /devices — list table, create dialog, delete with confirmation, status dots, refresh
|
- 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
|
- 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)
|
- 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: 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")
|
- 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
|
- 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)
|
- 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
|
- 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`
|
||||||
|
|||||||
3
packages/db/migrations/0001_mysterious_thor_girl.sql
Normal file
3
packages/db/migrations/0001_mysterious_thor_girl.sql
Normal 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;
|
||||||
@@ -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;
|
||||||
939
packages/db/migrations/meta/0001_snapshot.json
Normal file
939
packages/db/migrations/meta/0001_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
939
packages/db/migrations/meta/0002_snapshot.json
Normal file
939
packages/db/migrations/meta/0002_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,20 @@
|
|||||||
"when": 1774650657798,
|
"when": 1774650657798,
|
||||||
"tag": "0000_colorful_the_leader",
|
"tag": "0000_colorful_the_leader",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,10 +15,11 @@ export const device = pgTable("device", {
|
|||||||
|
|
||||||
status: varchar("status", { length: 16 }).notNull().default("offline"), // "online" | "offline" | "busy" | "error"
|
status: varchar("status", { length: 16 }).notNull().default("offline"), // "online" | "offline" | "busy" | "error"
|
||||||
isActive: boolean("is_active").notNull().default(false),
|
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
|
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(),
|
createdAt: timestamp("created_at").notNull(),
|
||||||
updatedAt: timestamp("updated_at").notNull(),
|
updatedAt: timestamp("updated_at").notNull(),
|
||||||
|
|||||||
@@ -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 { device } from "./device.schema";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
|
|
||||||
@@ -7,6 +14,8 @@ export const link = pgTable("link", {
|
|||||||
|
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
status: varchar("status", { length: 16 }).notNull().default("active"), // "active" | "inactive" | "expired" | "revoked"
|
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, {
|
linkedDeviceId: integer("linked_device_id").references(() => device.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ export class DeviceController {
|
|||||||
*/
|
*/
|
||||||
allocate(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
|
allocate(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
|
||||||
return this.repo.getById(fctx, id).andThen((dev) => {
|
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 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 a device back to online after a session ends.
|
||||||
*/
|
*/
|
||||||
release(fctx: FlowExecCtx, id: number): ResultAsync<Device, Err> {
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ export const deviceSchema = v.object({
|
|||||||
version: v.string(),
|
version: v.string(),
|
||||||
status: deviceStatusSchema,
|
status: deviceStatusSchema,
|
||||||
isActive: v.boolean(),
|
isActive: v.boolean(),
|
||||||
containerId: v.nullable(v.string()),
|
inUse: v.boolean(),
|
||||||
|
containerId: v.string(),
|
||||||
host: v.string(),
|
host: v.string(),
|
||||||
wsPort: v.nullable(v.string()),
|
wsPort: v.string(),
|
||||||
createdAt: v.date(),
|
createdAt: v.date(),
|
||||||
updatedAt: v.date(),
|
updatedAt: v.date(),
|
||||||
});
|
});
|
||||||
@@ -28,8 +29,8 @@ export const createDeviceSchema = v.object({
|
|||||||
title: v.pipe(v.string(), v.minLength(1)),
|
title: v.pipe(v.string(), v.minLength(1)),
|
||||||
version: v.pipe(v.string(), v.minLength(1)),
|
version: v.pipe(v.string(), v.minLength(1)),
|
||||||
host: v.pipe(v.string(), v.minLength(1)),
|
host: v.pipe(v.string(), v.minLength(1)),
|
||||||
containerId: v.optional(v.string()),
|
containerId: v.pipe(v.string(), v.minLength(1)),
|
||||||
wsPort: v.optional(v.string()),
|
wsPort: v.pipe(v.string(), v.minLength(1)),
|
||||||
isActive: v.optional(v.boolean()),
|
isActive: v.optional(v.boolean()),
|
||||||
});
|
});
|
||||||
export type CreateDevice = v.InferOutput<typeof createDeviceSchema>;
|
export type CreateDevice = v.InferOutput<typeof createDeviceSchema>;
|
||||||
@@ -39,9 +40,10 @@ export const updateDeviceSchema = v.partial(
|
|||||||
title: v.string(),
|
title: v.string(),
|
||||||
version: v.string(),
|
version: v.string(),
|
||||||
host: v.string(),
|
host: v.string(),
|
||||||
containerId: v.nullable(v.string()),
|
containerId: v.string(),
|
||||||
wsPort: v.nullable(v.string()),
|
wsPort: v.string(),
|
||||||
isActive: v.boolean(),
|
isActive: v.boolean(),
|
||||||
|
inUse: v.boolean(),
|
||||||
status: deviceStatusSchema,
|
status: deviceStatusSchema,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,10 +60,11 @@ export class DeviceRepository {
|
|||||||
title: data.title,
|
title: data.title,
|
||||||
version: data.version,
|
version: data.version,
|
||||||
host: data.host,
|
host: data.host,
|
||||||
containerId: data.containerId ?? null,
|
containerId: data.containerId,
|
||||||
wsPort: data.wsPort ?? null,
|
wsPort: data.wsPort,
|
||||||
status: DeviceStatus.OFFLINE,
|
status: DeviceStatus.OFFLINE,
|
||||||
isActive: data.isActive ?? false,
|
isActive: data.isActive ?? false,
|
||||||
|
inUse: false,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const linkSchema = v.object({
|
|||||||
id: v.number(),
|
id: v.number(),
|
||||||
token: v.string(),
|
token: v.string(),
|
||||||
status: linkStatusSchema,
|
status: linkStatusSchema,
|
||||||
|
appName: v.string(),
|
||||||
|
appPackage: v.string(),
|
||||||
linkedDeviceId: v.nullable(v.number()),
|
linkedDeviceId: v.nullable(v.number()),
|
||||||
expiresAt: v.nullable(v.date()),
|
expiresAt: v.nullable(v.date()),
|
||||||
lastAccessedAt: 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({
|
export const createLinkSchema = v.object({
|
||||||
token: v.pipe(v.string(), v.minLength(1)),
|
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())),
|
expiresAt: v.optional(v.nullable(v.date())),
|
||||||
});
|
});
|
||||||
export type CreateLink = v.InferOutput<typeof createLinkSchema>;
|
export type CreateLink = v.InferOutput<typeof createLinkSchema>;
|
||||||
@@ -39,6 +43,8 @@ export type CreateLink = v.InferOutput<typeof createLinkSchema>;
|
|||||||
export const updateLinkSchema = v.partial(
|
export const updateLinkSchema = v.partial(
|
||||||
v.object({
|
v.object({
|
||||||
status: linkStatusSchema,
|
status: linkStatusSchema,
|
||||||
|
appName: v.string(),
|
||||||
|
appPackage: v.string(),
|
||||||
linkedDeviceId: v.nullable(v.number()),
|
linkedDeviceId: v.nullable(v.number()),
|
||||||
expiresAt: v.nullable(v.date()),
|
expiresAt: v.nullable(v.date()),
|
||||||
lastAccessedAt: v.nullable(v.date()),
|
lastAccessedAt: v.nullable(v.date()),
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export class LinkRepository {
|
|||||||
.values({
|
.values({
|
||||||
token: data.token,
|
token: data.token,
|
||||||
status: "active",
|
status: "active",
|
||||||
|
appName: data.appName,
|
||||||
|
appPackage: data.appPackage,
|
||||||
linkedDeviceId: data.linkedDeviceId ?? null,
|
linkedDeviceId: data.linkedDeviceId ?? null,
|
||||||
expiresAt: data.expiresAt ?? null,
|
expiresAt: data.expiresAt ?? null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export const settingsSchema = v.object({
|
|||||||
internalApiKey: v.string(),
|
internalApiKey: v.string(),
|
||||||
debugKey: v.string(),
|
debugKey: v.string(),
|
||||||
|
|
||||||
processorApiUrl: v.string(),
|
orchestratorApiUrl: v.string(),
|
||||||
|
wsScrcpySvcUrl: v.string(),
|
||||||
|
|
||||||
betterAuthUrl: v.string(),
|
betterAuthUrl: v.string(),
|
||||||
betterAuthSecret: v.string(),
|
betterAuthSecret: v.string(),
|
||||||
@@ -32,19 +33,6 @@ export const settingsSchema = v.object({
|
|||||||
|
|
||||||
otelServiceName: v.string(),
|
otelServiceName: v.string(),
|
||||||
otelExporterOtlpHttpEndpoint: 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>;
|
export type Settings = v.InferOutput<typeof settingsSchema>;
|
||||||
@@ -95,15 +83,11 @@ function loadSettings(): Settings {
|
|||||||
internalApiKey: getEnv("INTERNAL_API_KEY"),
|
internalApiKey: getEnv("INTERNAL_API_KEY"),
|
||||||
debugKey: getEnv("DEBUG_KEY"),
|
debugKey: getEnv("DEBUG_KEY"),
|
||||||
|
|
||||||
processorApiUrl: getEnv("PROCESSOR_API_URL", "http://localhost:3000"),
|
orchestratorApiUrl: getEnv(
|
||||||
appBuilderApiUrl: getEnv(
|
"ORCHESTRATOR_API_URL",
|
||||||
"APP_BUILDER_API_URL",
|
"http://localhost:3000",
|
||||||
"http://localhost:3001",
|
|
||||||
),
|
|
||||||
appBuilderAssetsPublicUrl: getEnv(
|
|
||||||
"APP_BUILDER_ASSETS_PUBLIC_URL",
|
|
||||||
"http://localhost:3001",
|
|
||||||
),
|
),
|
||||||
|
wsScrcpySvcUrl: getEnv("WS_SCRCPY_SVC_URL"),
|
||||||
|
|
||||||
betterAuthUrl: getEnv("BETTER_AUTH_URL"),
|
betterAuthUrl: getEnv("BETTER_AUTH_URL"),
|
||||||
betterAuthSecret: getEnv("BETTER_AUTH_SECRET"),
|
betterAuthSecret: getEnv("BETTER_AUTH_SECRET"),
|
||||||
@@ -122,26 +106,6 @@ function loadSettings(): Settings {
|
|||||||
otelExporterOtlpHttpEndpoint: getEnv(
|
otelExporterOtlpHttpEndpoint: getEnv(
|
||||||
"OTEL_EXPORTER_OTLP_HTTP_ENDPOINT",
|
"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 {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user