major updates to device and links management in admin
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"name": "@apps/front",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "PORT=3000 tsx watch src/index.ts",
|
||||
"dev": "PORT=3001 tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"prod": "HOST=0.0.0.0 PORT=3000 tsx src/index.ts"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import LayoutDashboard from "@lucide/svelte/icons/layout-dashboard";
|
||||
import Smartphone from "@lucide/svelte/icons/smartphone";
|
||||
import { BellRingIcon, Link } from "@lucide/svelte";
|
||||
import UserCircle from "~icons/lucide/user-circle";
|
||||
|
||||
@@ -26,11 +25,6 @@ export const mainNavTree = [
|
||||
url: "/links",
|
||||
icon: Link,
|
||||
},
|
||||
{
|
||||
title: "Devices",
|
||||
url: "/devices",
|
||||
icon: Smartphone,
|
||||
},
|
||||
] as AppSidebarItem[];
|
||||
|
||||
export const secondaryNavTree = [
|
||||
@@ -46,6 +40,18 @@ export const secondaryNavTree = [
|
||||
},
|
||||
] as AppSidebarItem[];
|
||||
|
||||
export const SUPPORTED_APPS = [
|
||||
{
|
||||
title: "Gmail",
|
||||
packageName: "com.google.android.gm",
|
||||
},
|
||||
{
|
||||
title: "Outlook",
|
||||
packageName: "com.microsoft.outlook",
|
||||
},
|
||||
// will add more here when support increases
|
||||
];
|
||||
|
||||
export const COMPANY_NAME = "SaaS Template";
|
||||
export const WEBSITE_URL = "https://company.com";
|
||||
|
||||
|
||||
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,
|
||||
deleteDeviceSC,
|
||||
setDeviceStatusSC,
|
||||
updateDeviceSC,
|
||||
} from "./device.remote";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
@@ -12,9 +13,10 @@ type Device = {
|
||||
version: string;
|
||||
status: string;
|
||||
isActive: boolean;
|
||||
containerId: string | null;
|
||||
inUse: boolean;
|
||||
containerId: string;
|
||||
host: string;
|
||||
wsPort: string | null;
|
||||
wsPort: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
@@ -23,17 +25,24 @@ type CreateDeviceInput = {
|
||||
title: string;
|
||||
version: string;
|
||||
host: string;
|
||||
containerId?: string;
|
||||
wsPort?: string;
|
||||
containerId: string;
|
||||
wsPort: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
type UpdateDeviceInput = CreateDeviceInput & {
|
||||
status: "online" | "offline" | "busy" | "error";
|
||||
inUse: boolean;
|
||||
};
|
||||
|
||||
class DeviceViewModel {
|
||||
devices = $state<Device[]>([]);
|
||||
loading = $state(false);
|
||||
creating = $state(false);
|
||||
updating = $state(false);
|
||||
deletingId = $state<number | null>(null);
|
||||
showCreateDialog = $state(false);
|
||||
editingId = $state<number | null>(null);
|
||||
|
||||
async fetchDevices() {
|
||||
this.loading = true;
|
||||
@@ -123,6 +132,43 @@ class DeviceViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
async updateDevice(id: number, data: UpdateDeviceInput): Promise<boolean> {
|
||||
this.updating = true;
|
||||
this.editingId = id;
|
||||
try {
|
||||
const result = await updateDeviceSC({ id, data });
|
||||
if (result?.error) {
|
||||
toast.error(
|
||||
result.error.message || "Failed to update device",
|
||||
{
|
||||
description:
|
||||
result.error.description || "Please try again",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
toast.success("Device updated");
|
||||
if (result.data) {
|
||||
const updated = result.data as Device;
|
||||
this.devices = this.devices.map((device) =>
|
||||
device.id === id ? updated : device,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error("Failed to update device", {
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Please try again",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.updating = false;
|
||||
this.editingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async setStatus(id: number, status: string) {
|
||||
try {
|
||||
const result = await setDeviceStatusSC({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getLinkController } from "@pkg/logic/domains/link/controller";
|
||||
import { updateLinkSchema } from "@pkg/logic/domains/link/data";
|
||||
import { createLinkSchema, updateLinkSchema } from "@pkg/logic/domains/link/data";
|
||||
import { getFlowExecCtxForRemoteFuncs, unauthorized } from "$lib/core/server.utils";
|
||||
import { command, getRequestEvent, query } from "$app/server";
|
||||
import * as v from "valibot";
|
||||
@@ -32,10 +32,7 @@ export const getLinkByIdSQ = query(
|
||||
);
|
||||
|
||||
export const createLinkSC = command(
|
||||
v.object({
|
||||
linkedDeviceId: v.optional(v.nullable(v.number())),
|
||||
expiresAt: v.optional(v.nullable(v.date())),
|
||||
}),
|
||||
v.omit(createLinkSchema, ["token"]),
|
||||
async (payload) => {
|
||||
const event = getRequestEvent();
|
||||
const fctx = await getFlowExecCtxForRemoteFuncs(event.locals);
|
||||
|
||||
@@ -12,6 +12,8 @@ type Link = {
|
||||
id: number;
|
||||
token: string;
|
||||
status: string;
|
||||
appName: string;
|
||||
appPackage: string;
|
||||
linkedDeviceId: number | null;
|
||||
expiresAt: Date | null;
|
||||
lastAccessedAt: Date | null;
|
||||
@@ -24,6 +26,7 @@ type DeviceOption = {
|
||||
title: string;
|
||||
host: string;
|
||||
status: string;
|
||||
inUse: boolean;
|
||||
};
|
||||
|
||||
class LinkViewModel {
|
||||
@@ -71,6 +74,7 @@ class LinkViewModel {
|
||||
title: d.title,
|
||||
host: d.host,
|
||||
status: d.status,
|
||||
inUse: d.inUse,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
@@ -79,7 +83,9 @@ class LinkViewModel {
|
||||
}
|
||||
|
||||
async createLink(data: {
|
||||
linkedDeviceId?: number | null;
|
||||
linkedDeviceId: number;
|
||||
appName: string;
|
||||
appPackage: string;
|
||||
expiresAt?: Date | null;
|
||||
}): Promise<boolean> {
|
||||
this.creating = true;
|
||||
|
||||
@@ -1,11 +1,511 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import * as Table from "$lib/components/ui/table";
|
||||
import { goto } from "$app/navigation";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import DeviceForm from "$lib/domains/device/device-form.svelte";
|
||||
import { deviceVM } from "$lib/domains/device/device.vm.svelte";
|
||||
import { breadcrumbs } from "$lib/global.stores";
|
||||
import ChevronRight from "@lucide/svelte/icons/chevron-right";
|
||||
import Pencil from "@lucide/svelte/icons/pencil";
|
||||
import Monitor from "@lucide/svelte/icons/monitor";
|
||||
import Plus from "@lucide/svelte/icons/plus";
|
||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||
import Trash2 from "@lucide/svelte/icons/trash-2";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
breadcrumbs.set([mainNavTree[0]]);
|
||||
|
||||
let title = $state("");
|
||||
let version = $state("");
|
||||
let host = $state("");
|
||||
let containerId = $state("");
|
||||
let wsPort = $state("");
|
||||
let isActive = $state(false);
|
||||
let editId = $state<number | null>(null);
|
||||
let editTitle = $state("");
|
||||
let editVersion = $state("");
|
||||
let editHost = $state("");
|
||||
let editContainerId = $state("");
|
||||
let editWsPort = $state("");
|
||||
let editIsActive = $state(false);
|
||||
let editInUse = $state(false);
|
||||
let editStatus = $state("offline" as
|
||||
| "online"
|
||||
| "offline"
|
||||
| "busy"
|
||||
| "error");
|
||||
let showEditDialog = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await deviceVM.fetchDevices();
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
title = "";
|
||||
version = "";
|
||||
host = "";
|
||||
containerId = "";
|
||||
wsPort = "";
|
||||
isActive = false;
|
||||
}
|
||||
|
||||
function openEditDialog(device: (typeof deviceVM.devices)[number]) {
|
||||
editId = device.id;
|
||||
editTitle = device.title;
|
||||
editVersion = device.version;
|
||||
editHost = device.host;
|
||||
editContainerId = device.containerId;
|
||||
editWsPort = device.wsPort;
|
||||
editIsActive = device.isActive;
|
||||
editInUse = device.inUse;
|
||||
editStatus = device.status as typeof editStatus;
|
||||
showEditDialog = true;
|
||||
}
|
||||
|
||||
function resetEditForm() {
|
||||
editId = null;
|
||||
editTitle = "";
|
||||
editVersion = "";
|
||||
editHost = "";
|
||||
editContainerId = "";
|
||||
editWsPort = "";
|
||||
editIsActive = false;
|
||||
editInUse = false;
|
||||
editStatus = "offline";
|
||||
}
|
||||
|
||||
async function handleCreate(e: Event) {
|
||||
e.preventDefault();
|
||||
const success = await deviceVM.createDevice({
|
||||
title,
|
||||
version,
|
||||
host,
|
||||
containerId,
|
||||
wsPort,
|
||||
isActive,
|
||||
});
|
||||
if (success) resetForm();
|
||||
}
|
||||
|
||||
async function handleUpdate(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!editId) return;
|
||||
|
||||
const success = await deviceVM.updateDevice(editId, {
|
||||
title: editTitle,
|
||||
version: editVersion,
|
||||
host: editHost,
|
||||
containerId: editContainerId,
|
||||
wsPort: editWsPort,
|
||||
isActive: editIsActive,
|
||||
inUse: editInUse,
|
||||
status: editStatus,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
showEditDialog = false;
|
||||
resetEditForm();
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "bg-green-500";
|
||||
case "offline":
|
||||
return "bg-zinc-400";
|
||||
case "busy":
|
||||
return "bg-amber-500";
|
||||
case "error":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-zinc-400";
|
||||
}
|
||||
}
|
||||
|
||||
function openDeviceDetails(id: number) {
|
||||
void goto(`/dashboard/${id}`);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showEditDialog) return;
|
||||
resetEditForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<MaxWidthWrapper cls="space-y-4">
|
||||
<span>dunno</span>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={Monitor} cls="h-5 w-5 text-primary" />
|
||||
<Card.Title>Devices</Card.Title>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{deviceVM.devices.length} total
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => void deviceVM.fetchDevices()}
|
||||
disabled={deviceVM.loading}
|
||||
>
|
||||
<Icon
|
||||
icon={RefreshCw}
|
||||
cls={`h-4 w-4 mr-2 ${deviceVM.loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => (deviceVM.showCreateDialog = true)}
|
||||
>
|
||||
<Icon icon={Plus} cls="h-4 w-4 mr-2" />
|
||||
Add Device
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content>
|
||||
{#if !deviceVM.loading && deviceVM.devices.length === 0}
|
||||
<div class="py-10 text-center text-sm text-muted-foreground">
|
||||
No devices registered yet.
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Mobile cards -->
|
||||
<div class="space-y-3 md:hidden">
|
||||
{#each deviceVM.devices as device (device.id)}
|
||||
<div class="rounded-lg border bg-background p-3">
|
||||
<div
|
||||
class="flex items-start justify-between gap-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="group min-w-0 text-left"
|
||||
onclick={() => openDeviceDetails(device.id)}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p
|
||||
class="truncate text-[15px] font-semibold text-primary underline decoration-primary/40 underline-offset-4"
|
||||
>
|
||||
{device.title}
|
||||
</p>
|
||||
<Icon
|
||||
icon={ChevronRight}
|
||||
cls="h-3.5 w-3.5 shrink-0 text-primary/70 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="text-muted-foreground truncate text-xs"
|
||||
>
|
||||
{device.host}
|
||||
</p>
|
||||
</button>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs capitalize"
|
||||
>
|
||||
<span
|
||||
class={`h-2 w-2 rounded-full ${statusColor(device.status)}`}
|
||||
></span>
|
||||
{device.status}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs"
|
||||
>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Version</p>
|
||||
<p>{device.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Active</p>
|
||||
<p>{device.isActive ? "Yes" : "No"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">In Use</p>
|
||||
<p>{device.inUse ? "Yes" : "No"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">
|
||||
Container
|
||||
</p>
|
||||
<p class="truncate font-mono">{device.containerId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">
|
||||
WS Port
|
||||
</p>
|
||||
<p>{device.wsPort}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openEditDialog(device)}
|
||||
>
|
||||
<Icon icon={Pencil} cls="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={deviceVM.deletingId ===
|
||||
device.id}
|
||||
>
|
||||
<Icon icon={Trash2} cls="h-4 w-4" />
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Delete device?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This will permanently remove
|
||||
"{device.title}" and unlink it
|
||||
from any associated links.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={() =>
|
||||
deviceVM.deleteDevice(
|
||||
device.id,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden md:block">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Title</Table.Head>
|
||||
<Table.Head>Host</Table.Head>
|
||||
<Table.Head>Version</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Active</Table.Head>
|
||||
<Table.Head>In Use</Table.Head>
|
||||
<Table.Head>Container</Table.Head>
|
||||
<Table.Head>Actions</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each deviceVM.devices as device (device.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-1.5 text-left"
|
||||
onclick={() =>
|
||||
openDeviceDetails(device.id)}
|
||||
>
|
||||
<span
|
||||
class="text-[15px] font-semibold text-primary underline decoration-primary/40 underline-offset-4"
|
||||
>
|
||||
{device.title}
|
||||
</span>
|
||||
<Icon
|
||||
icon={ChevronRight}
|
||||
cls="h-3.5 w-3.5 shrink-0 text-primary/70 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span class="font-mono text-xs">
|
||||
{device.host}
|
||||
</span>
|
||||
{#if device.wsPort}
|
||||
<span
|
||||
class="text-muted-foreground text-xs"
|
||||
>
|
||||
:{device.wsPort}
|
||||
</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{device.version}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-xs capitalize"
|
||||
>
|
||||
<span
|
||||
class={`h-2 w-2 rounded-full ${statusColor(device.status)}`}
|
||||
></span>
|
||||
{device.status}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if device.isActive}
|
||||
<Badge variant="default"
|
||||
>Active</Badge
|
||||
>
|
||||
{:else}
|
||||
<Badge variant="secondary"
|
||||
>Inactive</Badge
|
||||
>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if device.inUse}
|
||||
<Badge variant="destructive"
|
||||
>In Use</Badge
|
||||
>
|
||||
{:else}
|
||||
<Badge variant="secondary"
|
||||
>Free</Badge
|
||||
>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="inline-block max-w-[120px] truncate font-mono text-xs"
|
||||
>
|
||||
{device.containerId}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
openEditDialog(device)}
|
||||
>
|
||||
<Icon
|
||||
icon={Pencil}
|
||||
cls="h-4 w-4"
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger
|
||||
class={buttonVariants({
|
||||
variant: "destructive",
|
||||
size: "sm",
|
||||
})}
|
||||
disabled={deviceVM.deletingId ===
|
||||
device.id}
|
||||
>
|
||||
<Icon
|
||||
icon={Trash2}
|
||||
cls="h-4 w-4"
|
||||
/>
|
||||
Delete
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>
|
||||
Delete device?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This will permanently
|
||||
remove "{device.title}"
|
||||
and unlink it from any
|
||||
associated links.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={() =>
|
||||
deviceVM.deleteDevice(
|
||||
device.id,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</MaxWidthWrapper>
|
||||
|
||||
<!-- Create Device Dialog -->
|
||||
<Dialog.Root bind:open={deviceVM.showCreateDialog}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Add Device</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Register a new Docker-Android instance.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<DeviceForm
|
||||
fieldPrefix="create-device"
|
||||
bind:title
|
||||
bind:version
|
||||
bind:host
|
||||
bind:containerId
|
||||
bind:wsPort
|
||||
bind:isActive
|
||||
submitLabel="Create"
|
||||
submitting={deviceVM.creating}
|
||||
onsubmit={handleCreate}
|
||||
oncancel={() => (deviceVM.showCreateDialog = false)}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<Dialog.Root bind:open={showEditDialog}>
|
||||
<Dialog.Content class="sm:max-w-[425px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit Device</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Update connection details or manually override device state.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<DeviceForm
|
||||
fieldPrefix="edit-device"
|
||||
bind:title={editTitle}
|
||||
bind:version={editVersion}
|
||||
bind:host={editHost}
|
||||
bind:containerId={editContainerId}
|
||||
bind:wsPort={editWsPort}
|
||||
bind:isActive={editIsActive}
|
||||
bind:inUse={editInUse}
|
||||
bind:status={editStatus}
|
||||
submitLabel="Save Changes"
|
||||
submitting={deviceVM.updating}
|
||||
showAdvanced={true}
|
||||
onsubmit={handleUpdate}
|
||||
oncancel={() => {
|
||||
showEditDialog = false;
|
||||
resetEditForm();
|
||||
}}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
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 * as Table from "$lib/components/ui/table";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import { mainNavTree } from "$lib/core/constants";
|
||||
import { mainNavTree, SUPPORTED_APPS } from "$lib/core/constants";
|
||||
import { linkVM } from "$lib/domains/link/link.vm.svelte";
|
||||
import { breadcrumbs } from "$lib/global.stores";
|
||||
import LinkIcon from "@lucide/svelte/icons/link";
|
||||
@@ -24,6 +24,7 @@
|
||||
breadcrumbs.set([mainNavTree[0], mainNavTree[1]]);
|
||||
|
||||
let selectedDeviceId = $state("");
|
||||
let selectedAppPackage = $state("");
|
||||
let expiresAt = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
@@ -33,15 +34,25 @@
|
||||
|
||||
function resetForm() {
|
||||
selectedDeviceId = "";
|
||||
selectedAppPackage = "";
|
||||
expiresAt = "";
|
||||
}
|
||||
|
||||
function getSupportedApp(packageName: string) {
|
||||
return SUPPORTED_APPS.find((app) => app.packageName === packageName);
|
||||
}
|
||||
|
||||
async function handleCreate(e: Event) {
|
||||
e.preventDefault();
|
||||
const selectedApp = getSupportedApp(selectedAppPackage);
|
||||
if (!selectedApp) {
|
||||
toast.error("Select a supported app");
|
||||
return;
|
||||
}
|
||||
const success = await linkVM.createLink({
|
||||
linkedDeviceId: selectedDeviceId
|
||||
? Number(selectedDeviceId)
|
||||
: null,
|
||||
linkedDeviceId: Number(selectedDeviceId),
|
||||
appName: selectedApp.title,
|
||||
appPackage: selectedApp.packageName,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
});
|
||||
if (success) resetForm();
|
||||
@@ -163,6 +174,15 @@
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">App</p>
|
||||
<p>{link.appName}</p>
|
||||
<p
|
||||
class="text-muted-foreground break-all font-mono text-[11px]"
|
||||
>
|
||||
{link.appPackage}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">Expires</p>
|
||||
<p>{formatDate(link.expiresAt)}</p>
|
||||
@@ -238,6 +258,7 @@
|
||||
<Table.Head>Token</Table.Head>
|
||||
<Table.Head>Status</Table.Head>
|
||||
<Table.Head>Device</Table.Head>
|
||||
<Table.Head>Leased App</Table.Head>
|
||||
<Table.Head>Expires</Table.Head>
|
||||
<Table.Head>Last Accessed</Table.Head>
|
||||
<Table.Head>Created</Table.Head>
|
||||
@@ -277,6 +298,18 @@
|
||||
link.linkedDeviceId,
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="space-y-0.5">
|
||||
<p class="text-sm font-medium">
|
||||
{link.appName}
|
||||
</p>
|
||||
<p
|
||||
class="text-muted-foreground font-mono text-[11px]"
|
||||
>
|
||||
{link.appPackage}
|
||||
</p>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs">
|
||||
{formatDate(link.expiresAt)}
|
||||
</Table.Cell>
|
||||
@@ -373,21 +406,44 @@
|
||||
</Dialog.Header>
|
||||
<form onsubmit={handleCreate} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="device">Assign Device</Label>
|
||||
<Label for="device">Assigned Device</Label>
|
||||
<select
|
||||
id="device"
|
||||
bind:value={selectedDeviceId}
|
||||
required
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<option value="">No device</option>
|
||||
<option value="" disabled>Select a device</option>
|
||||
{#each linkVM.availableDevices as device}
|
||||
<option value={String(device.id)}>
|
||||
{device.title} — {device.host}
|
||||
{device.title} — {device.host}{device.inUse
|
||||
? " (in use)"
|
||||
: ""}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Optional. You can assign a device later.
|
||||
Links now target one specific device.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="supportedApp">Supported App</Label>
|
||||
<select
|
||||
id="supportedApp"
|
||||
bind:value={selectedAppPackage}
|
||||
required
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1"
|
||||
>
|
||||
<option value="" disabled>Select a supported app</option>
|
||||
{#each SUPPORTED_APPS as app}
|
||||
<option value={app.packageName}>
|
||||
{app.title} — {app.packageName}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Links can only target apps from the current supported app
|
||||
list.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user