a new 'base app' which gon get deployed on prem
This commit is contained in:
@@ -10,7 +10,7 @@ Currently in alpha. Greenfield. Subject to change.
|
|||||||
|
|
||||||
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 user (or a slot).
|
||||||
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 waits while a Docker-Android device is allocated to their session.
|
3. User is shown a loading screen for good UX purposes
|
||||||
4. User is prompted to install the PWA.
|
4. User is prompted to install the PWA.
|
||||||
5. User opens the PWA — they are routed into a live stream of their assigned Android instance.
|
5. User opens the PWA — they are routed into a live stream of their assigned Android instance.
|
||||||
6. Admin manages the entire fleet from `apps/main` (the dashboard), which communicates with `apps/orchestrator` running on each VPS to control Docker-Android containers.
|
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.
|
||||||
@@ -54,11 +54,12 @@ Currently in alpha. Greenfield. Subject to change.
|
|||||||
- [ ] Admin dashboard: Links page — generate links, view detail, configure linked device, revoke, delete
|
- [ ] Admin dashboard: Links page — generate links, view detail, configure linked device, revoke, delete
|
||||||
- [ ] `apps/front`: validate incoming link token on request
|
- [ ] `apps/front`: validate incoming link token on request
|
||||||
- [ ] `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
|
||||||
|
|
||||||
### PWA & User Session Flow (`apps/front`)
|
### PWA & User Session Flow (`apps/front`)
|
||||||
|
|
||||||
- [ ] `apps/front`: serve static PWA shell (HTML + manifest + service worker)
|
- [ ] `apps/front`: serve static PWA shell (HTML + manifest + service worker)
|
||||||
- [ ] `apps/front`: wait/loading page — poll for device allocation status
|
- [ ] `apps/front`: wait/loading page — just for show with a 3-5s duration
|
||||||
- [ ] `apps/front`: PWA install prompt flow (beforeinstallprompt handling)
|
- [ ] `apps/front`: PWA install prompt flow (beforeinstallprompt handling)
|
||||||
- [ ] `apps/front`: session binding — tie the PWA launch to the user's allocated device
|
- [ ] `apps/front`: session binding — tie the PWA launch to the user's allocated device
|
||||||
- [ ] `apps/front`: route/proxy authenticated PWA requests to the Android instance stream
|
- [ ] `apps/front`: route/proxy authenticated PWA requests to the Android instance stream
|
||||||
|
|||||||
150
apps/front/old.server.ts
Normal file
150
apps/front/old.server.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Mobile Proxy Test — Bun Reverse Proxy Server
|
||||||
|
*
|
||||||
|
* Sits on port 3000 (what ngrok tunnels) and:
|
||||||
|
* GET / -> serves our clean mobile viewer (view.html)
|
||||||
|
* Everything else -> reverse-proxied to ws-scrcpy on port 8000
|
||||||
|
*
|
||||||
|
* WebSocket upgrades are proxied transparently so the ws-scrcpy
|
||||||
|
* player running inside our iframe can talk to the scrcpy server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WS_SCRCPY_ORIGIN = "http://localhost:8000";
|
||||||
|
const PROXY_PORT = 3000;
|
||||||
|
|
||||||
|
const viewHtml = await Bun.file(
|
||||||
|
new URL("./view.html", import.meta.url).pathname,
|
||||||
|
).text();
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: PROXY_PORT,
|
||||||
|
hostname: "0.0.0.0",
|
||||||
|
|
||||||
|
async fetch(req, server) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// Serve our clean mobile viewer at root only
|
||||||
|
// (the iframe loads ws-scrcpy's own page via /?scrcpy=1)
|
||||||
|
if (
|
||||||
|
(url.pathname === "/" || url.pathname === "/index.html") &&
|
||||||
|
!url.searchParams.has("scrcpy")
|
||||||
|
) {
|
||||||
|
return new Response(viewHtml, {
|
||||||
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade WebSocket connections — proxy to ws-scrcpy
|
||||||
|
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
||||||
|
const targetUrl = `${WS_SCRCPY_ORIGIN.replace("http", "ws")}${url.pathname}${url.search}`;
|
||||||
|
|
||||||
|
// Use Bun's WebSocket upgrade to establish a client-side WS to ws-scrcpy
|
||||||
|
// and bridge the two connections
|
||||||
|
const success = server.upgrade(req, {
|
||||||
|
data: { targetUrl },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) return undefined;
|
||||||
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy all other HTTP requests to ws-scrcpy
|
||||||
|
const targetUrl = `${WS_SCRCPY_ORIGIN}${url.pathname}${url.search}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proxyRes = await fetch(targetUrl, {
|
||||||
|
method: req.method,
|
||||||
|
headers: req.headers,
|
||||||
|
body:
|
||||||
|
req.method !== "GET" && req.method !== "HEAD"
|
||||||
|
? req.body
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clone response with CORS headers stripped / adjusted
|
||||||
|
const headers = new Headers(proxyRes.headers);
|
||||||
|
headers.delete("content-encoding"); // Bun handles this
|
||||||
|
|
||||||
|
return new Response(proxyRes.body, {
|
||||||
|
status: proxyRes.status,
|
||||||
|
statusText: proxyRes.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Proxy error for ${url.pathname}:`, err);
|
||||||
|
return new Response(
|
||||||
|
"ws-scrcpy not reachable — is it running on port 8000?",
|
||||||
|
{
|
||||||
|
status: 502,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
websocket: {
|
||||||
|
async open(ws) {
|
||||||
|
const { targetUrl } = ws.data as { targetUrl: string };
|
||||||
|
|
||||||
|
// Open a WebSocket connection to ws-scrcpy
|
||||||
|
const upstream = new WebSocket(targetUrl);
|
||||||
|
|
||||||
|
// Store upstream reference on ws data for cleanup
|
||||||
|
(ws.data as any).upstream = upstream;
|
||||||
|
|
||||||
|
upstream.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
upstream.onopen = () => {
|
||||||
|
// Connection established, nothing extra needed
|
||||||
|
};
|
||||||
|
|
||||||
|
upstream.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
ws.sendBinary(new Uint8Array(event.data));
|
||||||
|
} else {
|
||||||
|
ws.sendText(event.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Client disconnected
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
upstream.onclose = () => {
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
upstream.onerror = (err) => {
|
||||||
|
console.error("Upstream WS error:", err);
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
message(ws, message) {
|
||||||
|
const upstream = (ws.data as any).upstream as WebSocket | undefined;
|
||||||
|
if (!upstream || upstream.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof message === "string") {
|
||||||
|
upstream.send(message);
|
||||||
|
} else {
|
||||||
|
upstream.send(message);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Upstream disconnected
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
close(ws) {
|
||||||
|
const upstream = (ws.data as any).upstream as WebSocket | undefined;
|
||||||
|
if (upstream && upstream.readyState === WebSocket.OPEN) {
|
||||||
|
upstream.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n Mobile Proxy running on http://localhost:${server.port}`);
|
||||||
|
console.log(` Proxying to ws-scrcpy at ${WS_SCRCPY_ORIGIN}`);
|
||||||
|
console.log(
|
||||||
|
`\n Point ngrok at port ${server.port}, then open the ngrok URL on the user's phone.\n`,
|
||||||
|
);
|
||||||
@@ -163,9 +163,9 @@
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
function injectCSS(doc) {
|
function injectCSS(doc) {
|
||||||
if (doc.querySelector("#maitm-hide-css")) return;
|
if (doc.querySelector("#iotam-hide-css")) return;
|
||||||
const style = doc.createElement("style");
|
const style = doc.createElement("style");
|
||||||
style.id = "maitm-hide-css";
|
style.id = "iotam-hide-css";
|
||||||
style.textContent = HIDE_CSS;
|
style.textContent = HIDE_CSS;
|
||||||
doc.head.appendChild(style);
|
doc.head.appendChild(style);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ export const first: Handle = async ({ event, resolve }) => {
|
|||||||
event.url.pathname.includes("/api/auth") ||
|
event.url.pathname.includes("/api/auth") ||
|
||||||
event.url.pathname.includes("/api/debug") ||
|
event.url.pathname.includes("/api/debug") ||
|
||||||
event.url.pathname.includes("/api/chat") ||
|
event.url.pathname.includes("/api/chat") ||
|
||||||
event.url.pathname.includes("/auth") ||
|
event.url.pathname.includes("/auth")
|
||||||
event.url.pathname.includes("/link")
|
|
||||||
) {
|
) {
|
||||||
return await resolve(event);
|
return await resolve(event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ class DeviceViewModel {
|
|||||||
}
|
}
|
||||||
toast.success("Device created");
|
toast.success("Device created");
|
||||||
this.showCreateDialog = false;
|
this.showCreateDialog = false;
|
||||||
await this.fetchDevices();
|
if (result.data) {
|
||||||
|
this.devices = [...this.devices, result.data as Device];
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to create device", {
|
toast.error("Failed to create device", {
|
||||||
@@ -108,7 +110,7 @@ class DeviceViewModel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success("Device deleted");
|
toast.success("Device deleted");
|
||||||
await this.fetchDevices();
|
this.devices = this.devices.filter((d) => d.id !== id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to delete device", {
|
toast.error("Failed to delete device", {
|
||||||
description:
|
description:
|
||||||
|
|||||||
196
apps/main/src/lib/domains/link/link.vm.svelte.ts
Normal file
196
apps/main/src/lib/domains/link/link.vm.svelte.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import {
|
||||||
|
listLinksSQ,
|
||||||
|
createLinkSC,
|
||||||
|
revokeLinkSC,
|
||||||
|
deleteLinkSC,
|
||||||
|
assignDeviceSC,
|
||||||
|
} from "./link.remote";
|
||||||
|
import { listDevicesSQ } from "../device/device.remote";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
|
type Link = {
|
||||||
|
id: number;
|
||||||
|
token: string;
|
||||||
|
status: string;
|
||||||
|
linkedDeviceId: number | null;
|
||||||
|
expiresAt: Date | null;
|
||||||
|
lastAccessedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeviceOption = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
host: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
class LinkViewModel {
|
||||||
|
links = $state<Link[]>([]);
|
||||||
|
availableDevices = $state<DeviceOption[]>([]);
|
||||||
|
loading = $state(false);
|
||||||
|
creating = $state(false);
|
||||||
|
deletingId = $state<number | null>(null);
|
||||||
|
revokingId = $state<number | null>(null);
|
||||||
|
showCreateDialog = $state(false);
|
||||||
|
|
||||||
|
async fetchLinks() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const result = await listLinksSQ();
|
||||||
|
if (result?.error || !result?.data) {
|
||||||
|
toast.error(
|
||||||
|
result?.error?.message || "Failed to fetch links",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
result?.error?.description || "Please try again",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.links = result.data as Link[];
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to fetch links", {
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Please try again",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchDevicesForSelect() {
|
||||||
|
try {
|
||||||
|
const result = await listDevicesSQ();
|
||||||
|
if (result?.data) {
|
||||||
|
this.availableDevices = (result.data as any[]).map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
title: d.title,
|
||||||
|
host: d.host,
|
||||||
|
status: d.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — select will just be empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLink(data: {
|
||||||
|
linkedDeviceId?: number | null;
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
this.creating = true;
|
||||||
|
try {
|
||||||
|
const result = await createLinkSC(data);
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error(result.error.message || "Failed to create link", {
|
||||||
|
description:
|
||||||
|
result.error.description || "Please try again",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
toast.success("Link created");
|
||||||
|
this.showCreateDialog = false;
|
||||||
|
if (result.data) {
|
||||||
|
this.links = [...this.links, result.data as Link];
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to create link", {
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Please try again",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeLink(id: number) {
|
||||||
|
this.revokingId = id;
|
||||||
|
try {
|
||||||
|
const result = await revokeLinkSC({ id });
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error(result.error.message || "Failed to revoke link", {
|
||||||
|
description:
|
||||||
|
result.error.description || "Please try again",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("Link revoked");
|
||||||
|
await this.fetchLinks();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to revoke link", {
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Please try again",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.revokingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLink(id: number) {
|
||||||
|
this.deletingId = id;
|
||||||
|
try {
|
||||||
|
const result = await deleteLinkSC({ id });
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error(result.error.message || "Failed to delete link", {
|
||||||
|
description:
|
||||||
|
result.error.description || "Please try again",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("Link deleted");
|
||||||
|
this.links = this.links.filter((l) => l.id !== id);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to delete link", {
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Please try again",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.deletingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignDevice(linkId: number, deviceId: number | null) {
|
||||||
|
try {
|
||||||
|
const result = await assignDeviceSC({ id: linkId, deviceId });
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error(
|
||||||
|
result.error.message || "Failed to assign device",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
result.error.description || "Please try again",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(deviceId ? "Device assigned" : "Device unassigned");
|
||||||
|
await this.fetchLinks();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to assign device", {
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Please try again",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDeviceName(deviceId: number | null): string {
|
||||||
|
if (!deviceId) return "—";
|
||||||
|
const device = this.availableDevices.find((d) => d.id === deviceId);
|
||||||
|
return device ? device.title : `Device #${deviceId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const linkVM = new LinkViewModel();
|
||||||
@@ -1,267 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
|
||||||
import { Button } from "$lib/components/ui/button";
|
|
||||||
import { buttonVariants } from "$lib/components/ui/button";
|
|
||||||
import * as Card from "$lib/components/ui/card";
|
|
||||||
import { Input } from "$lib/components/ui/input";
|
|
||||||
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 } from "$lib/core/constants";
|
||||||
import { filesVM } from "$lib/domains/files/files.vm.svelte";
|
|
||||||
import { mobileVM } from "$lib/domains/mobile/mobile.vm.svelte";
|
|
||||||
import { breadcrumbs } from "$lib/global.stores";
|
import { breadcrumbs } from "$lib/global.stores";
|
||||||
import Smartphone from "@lucide/svelte/icons/smartphone";
|
|
||||||
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
|
||||||
import Search from "@lucide/svelte/icons/search";
|
|
||||||
import Trash2 from "@lucide/svelte/icons/trash-2";
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
|
|
||||||
breadcrumbs.set([mainNavTree[0]]);
|
breadcrumbs.set([mainNavTree[0]]);
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await mobileVM.refreshDevices();
|
|
||||||
mobileVM.startDevicesPolling(5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
mobileVM.stopDevicesPolling();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<MaxWidthWrapper cls="space-y-4">
|
<MaxWidthWrapper cls="space-y-4">
|
||||||
<Card.Root>
|
<span>dunno</span>
|
||||||
<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={Smartphone} cls="h-5 w-5 text-primary" />
|
|
||||||
<Card.Title>Devices</Card.Title>
|
|
||||||
<span class="text-muted-foreground text-xs">
|
|
||||||
{mobileVM.devicesTotal} total
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-2 sm:flex sm:items-center">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => void filesVM.cleanupDanglingFiles()}
|
|
||||||
disabled={filesVM.cleanupLoading}
|
|
||||||
class="col-span-1"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={Trash2}
|
|
||||||
cls={`h-4 w-4 mr-2 ${filesVM.cleanupLoading ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
<span class="truncate">Cleanup Storage</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => void mobileVM.refreshDevices()}
|
|
||||||
disabled={mobileVM.devicesLoading}
|
|
||||||
class="col-span-1"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={RefreshCw}
|
|
||||||
cls={`h-4 w-4 mr-2 ${mobileVM.devicesLoading ? "animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
<span class="hidden sm:inline">Refresh</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative mt-2 w-full sm:max-w-sm">
|
|
||||||
<Icon
|
|
||||||
icon={Search}
|
|
||||||
cls="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
class="pl-10"
|
|
||||||
placeholder="Search device name/id/model..."
|
|
||||||
bind:value={mobileVM.devicesSearch}
|
|
||||||
oninput={() => {
|
|
||||||
mobileVM.devicesPage = 1;
|
|
||||||
void mobileVM.refreshDevices();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card.Header>
|
|
||||||
|
|
||||||
<Card.Content>
|
|
||||||
{#if !mobileVM.devicesLoading && mobileVM.devices.length === 0}
|
|
||||||
<div class="py-10 text-center text-sm text-muted-foreground">
|
|
||||||
No devices registered yet.
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3 md:hidden">
|
|
||||||
{#each mobileVM.devices as device (device.id)}
|
|
||||||
<div
|
|
||||||
class="rounded-lg border bg-background p-3"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onclick={() => goto(`/devices/${device.id}`)}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
void goto(`/devices/${device.id}`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="truncate text-sm font-medium">{device.name}</p>
|
|
||||||
<p class="text-muted-foreground truncate text-xs">
|
|
||||||
{device.externalDeviceId}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<AlertDialog.Root>
|
|
||||||
<AlertDialog.Trigger
|
|
||||||
class={buttonVariants({
|
|
||||||
variant: "destructive",
|
|
||||||
size: "sm",
|
|
||||||
})}
|
|
||||||
disabled={mobileVM.deletingDeviceId === device.id}
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Icon icon={Trash2} cls="h-4 w-4" />
|
|
||||||
</AlertDialog.Trigger>
|
|
||||||
<AlertDialog.Content>
|
|
||||||
<AlertDialog.Header>
|
|
||||||
<AlertDialog.Title>
|
|
||||||
Delete device?
|
|
||||||
</AlertDialog.Title>
|
|
||||||
<AlertDialog.Description>
|
|
||||||
This deletes the device and all related SMS/media data.
|
|
||||||
Files in storage linked to this device are also removed.
|
|
||||||
</AlertDialog.Description>
|
|
||||||
</AlertDialog.Header>
|
|
||||||
<AlertDialog.Footer>
|
|
||||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
|
||||||
<AlertDialog.Action
|
|
||||||
onclick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
await mobileVM.deleteDevice(device.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</AlertDialog.Action>
|
|
||||||
</AlertDialog.Footer>
|
|
||||||
</AlertDialog.Content>
|
|
||||||
</AlertDialog.Root>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
|
|
||||||
<div>
|
|
||||||
<p class="text-muted-foreground">Manufacturer / Model</p>
|
|
||||||
<p class="truncate">{device.manufacturer} / {device.model}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-muted-foreground">Android</p>
|
|
||||||
<p>{device.androidVersion}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-muted-foreground">Created</p>
|
|
||||||
<p class="truncate">
|
|
||||||
{new Date(device.createdAt).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-muted-foreground">Last Ping</p>
|
|
||||||
<p class="truncate">
|
|
||||||
{mobileVM.formatLastPing(device.lastPingAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hidden md:block">
|
|
||||||
<Table.Root>
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Head>Device</Table.Head>
|
|
||||||
<Table.Head>Manufacturer / Model</Table.Head>
|
|
||||||
<Table.Head>Android</Table.Head>
|
|
||||||
<Table.Head>Created</Table.Head>
|
|
||||||
<Table.Head>Last Ping</Table.Head>
|
|
||||||
<Table.Head>Actions</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each mobileVM.devices as device (device.id)}
|
|
||||||
<Table.Row
|
|
||||||
class="cursor-pointer"
|
|
||||||
onclick={() => goto(`/devices/${device.id}`)}
|
|
||||||
>
|
|
||||||
<Table.Cell>
|
|
||||||
<div class="font-medium">{device.name}</div>
|
|
||||||
<div class="text-muted-foreground text-xs">
|
|
||||||
{device.externalDeviceId}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
{device.manufacturer} / {device.model}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>{device.androidVersion}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
{new Date(device.createdAt).toLocaleString()}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
{mobileVM.formatLastPing(device.lastPingAt)}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<AlertDialog.Root>
|
|
||||||
<AlertDialog.Trigger
|
|
||||||
class={buttonVariants({
|
|
||||||
variant: "destructive",
|
|
||||||
size: "sm",
|
|
||||||
})}
|
|
||||||
disabled={mobileVM.deletingDeviceId ===
|
|
||||||
device.id}
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={Trash2}
|
|
||||||
cls="h-4 w-4"
|
|
||||||
/>
|
|
||||||
Delete
|
|
||||||
</AlertDialog.Trigger>
|
|
||||||
<AlertDialog.Content>
|
|
||||||
<AlertDialog.Header>
|
|
||||||
<AlertDialog.Title>
|
|
||||||
Delete device?
|
|
||||||
</AlertDialog.Title>
|
|
||||||
<AlertDialog.Description>
|
|
||||||
This deletes the device and all related SMS/media data.
|
|
||||||
Files in storage linked to this device are also removed.
|
|
||||||
</AlertDialog.Description>
|
|
||||||
</AlertDialog.Header>
|
|
||||||
<AlertDialog.Footer>
|
|
||||||
<AlertDialog.Cancel>
|
|
||||||
Cancel
|
|
||||||
</AlertDialog.Cancel>
|
|
||||||
<AlertDialog.Action
|
|
||||||
onclick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
await mobileVM.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>
|
</MaxWidthWrapper>
|
||||||
|
|||||||
@@ -1 +1,399 @@
|
|||||||
<span>Show the running devices list here</span>
|
<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
apps/main/src/routes/(main)/devices/[id]/+page.svelte
Normal file
1
apps/main/src/routes/(main)/devices/[id]/+page.svelte
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<span>device id page</span>
|
||||||
@@ -1 +1,418 @@
|
|||||||
<span>everything related to links here</span>
|
<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 * as Table from "$lib/components/ui/table";
|
||||||
|
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||||
|
import { mainNavTree } 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";
|
||||||
|
import Plus from "@lucide/svelte/icons/plus";
|
||||||
|
import RefreshCw from "@lucide/svelte/icons/refresh-cw";
|
||||||
|
import Trash2 from "@lucide/svelte/icons/trash-2";
|
||||||
|
import Ban from "@lucide/svelte/icons/ban";
|
||||||
|
import Copy from "@lucide/svelte/icons/copy";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
|
||||||
|
breadcrumbs.set([mainNavTree[0], mainNavTree[1]]);
|
||||||
|
|
||||||
|
let selectedDeviceId = $state("");
|
||||||
|
let expiresAt = $state("");
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await linkVM.fetchLinks();
|
||||||
|
await linkVM.fetchDevicesForSelect();
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
selectedDeviceId = "";
|
||||||
|
expiresAt = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
const success = await linkVM.createLink({
|
||||||
|
linkedDeviceId: selectedDeviceId
|
||||||
|
? Number(selectedDeviceId)
|
||||||
|
: null,
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||||
|
});
|
||||||
|
if (success) resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken(token: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(token);
|
||||||
|
toast.success("Token copied to clipboard");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to copy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(
|
||||||
|
status: string,
|
||||||
|
): "default" | "secondary" | "destructive" | "outline" {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "default";
|
||||||
|
case "inactive":
|
||||||
|
return "secondary";
|
||||||
|
case "revoked":
|
||||||
|
return "destructive";
|
||||||
|
case "expired":
|
||||||
|
return "outline";
|
||||||
|
default:
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date | string | null): string {
|
||||||
|
if (!date) return "—";
|
||||||
|
return new Date(date).toLocaleString();
|
||||||
|
}
|
||||||
|
</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={LinkIcon} cls="h-5 w-5 text-primary" />
|
||||||
|
<Card.Title>Links</Card.Title>
|
||||||
|
<span class="text-muted-foreground text-xs">
|
||||||
|
{linkVM.links.length} total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => void linkVM.fetchLinks()}
|
||||||
|
disabled={linkVM.loading}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={RefreshCw}
|
||||||
|
cls={`h-4 w-4 mr-2 ${linkVM.loading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
linkVM.showCreateDialog = true;
|
||||||
|
linkVM.fetchDevicesForSelect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon={Plus} cls="h-4 w-4 mr-2" />
|
||||||
|
Generate Link
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content>
|
||||||
|
{#if !linkVM.loading && linkVM.links.length === 0}
|
||||||
|
<div class="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No links generated yet.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Mobile cards -->
|
||||||
|
<div class="space-y-3 md:hidden">
|
||||||
|
{#each linkVM.links as link (link.id)}
|
||||||
|
<div class="rounded-lg border bg-background p-3">
|
||||||
|
<div
|
||||||
|
class="flex items-start justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code
|
||||||
|
class="truncate text-sm font-medium"
|
||||||
|
>
|
||||||
|
{link.token}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
class="text-muted-foreground hover:text-foreground shrink-0"
|
||||||
|
onclick={() =>
|
||||||
|
copyToken(link.token)}
|
||||||
|
>
|
||||||
|
<Copy class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={statusVariant(link.status)}>
|
||||||
|
{link.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Device</p>
|
||||||
|
<p>
|
||||||
|
{linkVM.getDeviceName(
|
||||||
|
link.linkedDeviceId,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Expires</p>
|
||||||
|
<p>{formatDate(link.expiresAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
Last Accessed
|
||||||
|
</p>
|
||||||
|
<p>{formatDate(link.lastAccessedAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Created</p>
|
||||||
|
<p>{formatDate(link.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex justify-end gap-2">
|
||||||
|
{#if link.status === "active"}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() =>
|
||||||
|
linkVM.revokeLink(link.id)}
|
||||||
|
disabled={linkVM.revokingId === link.id}
|
||||||
|
>
|
||||||
|
<Icon icon={Ban} cls="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<AlertDialog.Root>
|
||||||
|
<AlertDialog.Trigger
|
||||||
|
class={buttonVariants({
|
||||||
|
variant: "destructive",
|
||||||
|
size: "sm",
|
||||||
|
})}
|
||||||
|
disabled={linkVM.deletingId === link.id}
|
||||||
|
>
|
||||||
|
<Icon icon={Trash2} cls="h-4 w-4" />
|
||||||
|
</AlertDialog.Trigger>
|
||||||
|
<AlertDialog.Content>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>
|
||||||
|
Delete link?
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
This will permanently remove
|
||||||
|
this link. Anyone with this
|
||||||
|
token will no longer be able to
|
||||||
|
access the service.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Cancel>
|
||||||
|
Cancel
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action
|
||||||
|
onclick={() =>
|
||||||
|
linkVM.deleteLink(link.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>Token</Table.Head>
|
||||||
|
<Table.Head>Status</Table.Head>
|
||||||
|
<Table.Head>Device</Table.Head>
|
||||||
|
<Table.Head>Expires</Table.Head>
|
||||||
|
<Table.Head>Last Accessed</Table.Head>
|
||||||
|
<Table.Head>Created</Table.Head>
|
||||||
|
<Table.Head>Actions</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each linkVM.links as link (link.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<code class="text-sm">
|
||||||
|
{link.token}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
class="text-muted-foreground hover:text-foreground"
|
||||||
|
onclick={() =>
|
||||||
|
copyToken(link.token)}
|
||||||
|
>
|
||||||
|
<Copy class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge
|
||||||
|
variant={statusVariant(
|
||||||
|
link.status,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{link.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
{linkVM.getDeviceName(
|
||||||
|
link.linkedDeviceId,
|
||||||
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-xs">
|
||||||
|
{formatDate(link.expiresAt)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-xs">
|
||||||
|
{formatDate(link.lastAccessedAt)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-xs">
|
||||||
|
{formatDate(link.createdAt)}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#if link.status === "active"}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() =>
|
||||||
|
linkVM.revokeLink(
|
||||||
|
link.id,
|
||||||
|
)}
|
||||||
|
disabled={linkVM.revokingId ===
|
||||||
|
link.id}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={Ban}
|
||||||
|
cls="h-4 w-4 mr-1"
|
||||||
|
/>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<AlertDialog.Root>
|
||||||
|
<AlertDialog.Trigger
|
||||||
|
class={buttonVariants({
|
||||||
|
variant: "destructive",
|
||||||
|
size: "sm",
|
||||||
|
})}
|
||||||
|
disabled={linkVM.deletingId ===
|
||||||
|
link.id}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={Trash2}
|
||||||
|
cls="h-4 w-4"
|
||||||
|
/>
|
||||||
|
Delete
|
||||||
|
</AlertDialog.Trigger>
|
||||||
|
<AlertDialog.Content>
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>
|
||||||
|
Delete link?
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>
|
||||||
|
This will
|
||||||
|
permanently remove
|
||||||
|
this link. Anyone
|
||||||
|
with this token will
|
||||||
|
no longer be able to
|
||||||
|
access the service.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Cancel>
|
||||||
|
Cancel
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action
|
||||||
|
onclick={() =>
|
||||||
|
linkVM.deleteLink(
|
||||||
|
link.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 Link Dialog -->
|
||||||
|
<Dialog.Root bind:open={linkVM.showCreateDialog}>
|
||||||
|
<Dialog.Content class="sm:max-w-[425px]">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Generate Link</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Create a new access link. The token is auto-generated.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<form onsubmit={handleCreate} class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="device">Assign Device</Label>
|
||||||
|
<select
|
||||||
|
id="device"
|
||||||
|
bind:value={selectedDeviceId}
|
||||||
|
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>
|
||||||
|
{#each linkVM.availableDevices as device}
|
||||||
|
<option value={String(device.id)}>
|
||||||
|
{device.title} — {device.host}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Optional. You can assign a device later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="expiresAt">Expires At</Label>
|
||||||
|
<Input
|
||||||
|
id="expiresAt"
|
||||||
|
type="datetime-local"
|
||||||
|
bind:value={expiresAt}
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
Optional. Leave empty for no expiry.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onclick={() => (linkVM.showCreateDialog = false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={linkVM.creating}>
|
||||||
|
{linkVM.creating ? "Generating..." : "Generate"}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</form>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|||||||
@@ -20,41 +20,41 @@
|
|||||||
--popover: oklch(0.991 0 0);
|
--popover: oklch(0.991 0 0);
|
||||||
--popover-foreground: oklch(0 0 0);
|
--popover-foreground: oklch(0 0 0);
|
||||||
|
|
||||||
/* --- main theme: lavender/royal purple --- */
|
/* --- main theme: teal --- */
|
||||||
--primary: oklch(0.6 0.2 280); /* medium lavender purple */
|
--primary: oklch(0.6 0.15 180); /* medium teal */
|
||||||
--primary-foreground: oklch(0.99 0 0);
|
--primary-foreground: oklch(0.99 0 0);
|
||||||
|
|
||||||
--secondary: oklch(0.93 0.05 285); /* soft pale lavender */
|
--secondary: oklch(0.93 0.04 178); /* soft pale teal */
|
||||||
--secondary-foreground: oklch(0.25 0.03 285);
|
--secondary-foreground: oklch(0.25 0.03 180);
|
||||||
|
|
||||||
--muted: oklch(0.96 0.01 275);
|
--muted: oklch(0.96 0.01 175);
|
||||||
--muted-foreground: oklch(0.4 0.01 278);
|
--muted-foreground: oklch(0.4 0.01 178);
|
||||||
|
|
||||||
--accent: oklch(0.86 0.08 275); /* lavender accent */
|
--accent: oklch(0.86 0.07 175); /* teal accent */
|
||||||
--accent-foreground: oklch(0.5 0.15 280);
|
--accent-foreground: oklch(0.5 0.12 180);
|
||||||
|
|
||||||
--destructive: oklch(0.63 0.18 25);
|
--destructive: oklch(0.63 0.18 25);
|
||||||
--destructive-foreground: oklch(1 0 0);
|
--destructive-foreground: oklch(1 0 0);
|
||||||
|
|
||||||
--border: oklch(0.92 0.02 284);
|
--border: oklch(0.92 0.02 178);
|
||||||
--input: oklch(0.94 0 0);
|
--input: oklch(0.94 0 0);
|
||||||
--ring: oklch(0.6 0.2 280);
|
--ring: oklch(0.6 0.15 180);
|
||||||
|
|
||||||
/* charts — more variety but still within lavender spectrum */
|
/* charts — variety within teal spectrum */
|
||||||
--chart-1: oklch(0.7 0.16 275);
|
--chart-1: oklch(0.7 0.13 175);
|
||||||
--chart-2: oklch(0.6 0.2 280);
|
--chart-2: oklch(0.6 0.15 180);
|
||||||
--chart-3: oklch(0.72 0.18 295); /* slightly more magenta */
|
--chart-3: oklch(0.72 0.14 165); /* slightly more green-teal */
|
||||||
--chart-4: oklch(0.65 0.15 265); /* slightly bluer lavender */
|
--chart-4: oklch(0.65 0.12 190); /* slightly bluer teal */
|
||||||
--chart-5: oklch(0.76 0.1 285);
|
--chart-5: oklch(0.76 0.09 182);
|
||||||
|
|
||||||
--sidebar: oklch(0.97 0.01 280);
|
--sidebar: oklch(0.97 0.01 178);
|
||||||
--sidebar-foreground: oklch(0 0 0);
|
--sidebar-foreground: oklch(0 0 0);
|
||||||
--sidebar-primary: oklch(0.6 0.2 280);
|
--sidebar-primary: oklch(0.6 0.15 180);
|
||||||
--sidebar-primary-foreground: oklch(1 0 0);
|
--sidebar-primary-foreground: oklch(1 0 0);
|
||||||
--sidebar-accent: oklch(0.92 0.02 284);
|
--sidebar-accent: oklch(0.92 0.02 178);
|
||||||
--sidebar-accent-foreground: oklch(0.2 0.02 280);
|
--sidebar-accent-foreground: oklch(0.2 0.02 180);
|
||||||
--sidebar-border: oklch(0.92 0.02 284);
|
--sidebar-border: oklch(0.92 0.02 178);
|
||||||
--sidebar-ring: oklch(0.6 0.2 280);
|
--sidebar-ring: oklch(0.6 0.15 180);
|
||||||
|
|
||||||
--font-sans: Plus Jakarta Sans, sans-serif;
|
--font-sans: Plus Jakarta Sans, sans-serif;
|
||||||
--font-serif: Lora, serif;
|
--font-serif: Lora, serif;
|
||||||
@@ -86,49 +86,48 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.23 0.01 278);
|
--background: oklch(0.23 0.01 178);
|
||||||
--foreground: oklch(0.95 0 0);
|
--foreground: oklch(0.95 0 0);
|
||||||
|
|
||||||
--card: oklch(0.25 0.015 278);
|
--card: oklch(0.25 0.015 178);
|
||||||
--card-foreground: oklch(0.95 0 0);
|
--card-foreground: oklch(0.95 0 0);
|
||||||
|
|
||||||
--popover: oklch(0.25 0.015 278);
|
--popover: oklch(0.25 0.015 178);
|
||||||
--popover-foreground: oklch(0.95 0 0);
|
--popover-foreground: oklch(0.95 0 0);
|
||||||
|
|
||||||
--primary: oklch(0.56 0.17 280);
|
--primary: oklch(0.56 0.13 180);
|
||||||
--primary-foreground: oklch(0.97 0 0);
|
--primary-foreground: oklch(0.97 0 0);
|
||||||
|
|
||||||
--secondary: oklch(0.35 0.03 280);
|
--secondary: oklch(0.35 0.03 180);
|
||||||
--secondary-foreground: oklch(0.92 0 0);
|
--secondary-foreground: oklch(0.92 0 0);
|
||||||
|
|
||||||
--muted: oklch(0.33 0.02 280);
|
--muted: oklch(0.33 0.02 178);
|
||||||
--muted-foreground: oklch(0.7 0.01 280);
|
--muted-foreground: oklch(0.7 0.01 178);
|
||||||
|
|
||||||
--accent: oklch(0.44 0.1 278);
|
--accent: oklch(0.44 0.08 178);
|
||||||
--accent-foreground: oklch(0.88 0.09 280);
|
--accent-foreground: oklch(0.88 0.08 180);
|
||||||
|
|
||||||
--destructive: oklch(0.7 0.17 25);
|
--destructive: oklch(0.7 0.17 25);
|
||||||
--destructive-foreground: oklch(1 0 0);
|
--destructive-foreground: oklch(1 0 0);
|
||||||
|
|
||||||
--border: oklch(0.34 0.02 278);
|
--border: oklch(0.34 0.02 178);
|
||||||
--input: oklch(0.34 0.02 278);
|
--input: oklch(0.34 0.02 178);
|
||||||
--ring: oklch(0.65 0.22 280);
|
--ring: oklch(0.56 0.13 180);
|
||||||
--ring: oklch(0.56 0.17 280);
|
|
||||||
|
|
||||||
--chart-1: oklch(0.68 0.15 275);
|
--chart-1: oklch(0.68 0.12 175);
|
||||||
--chart-2: oklch(0.62 0.2 280);
|
--chart-2: oklch(0.62 0.15 180);
|
||||||
--chart-3: oklch(0.7 0.14 292);
|
--chart-3: oklch(0.7 0.11 165);
|
||||||
--chart-4: oklch(0.65 0.16 265);
|
--chart-4: oklch(0.65 0.13 190);
|
||||||
--chart-5: oklch(0.72 0.1 285);
|
--chart-5: oklch(0.72 0.09 182);
|
||||||
|
|
||||||
--sidebar: oklch(0.2 0.01 278);
|
--sidebar: oklch(0.2 0.01 178);
|
||||||
--sidebar-foreground: oklch(0.95 0 0);
|
--sidebar-foreground: oklch(0.95 0 0);
|
||||||
--sidebar-primary: oklch(0.56 0.17 280);
|
--sidebar-primary: oklch(0.56 0.13 180);
|
||||||
--sidebar-primary-foreground: oklch(0.97 0 0);
|
--sidebar-primary-foreground: oklch(0.97 0 0);
|
||||||
--sidebar-accent: oklch(0.35 0.03 280);
|
--sidebar-accent: oklch(0.35 0.03 180);
|
||||||
--sidebar-accent-foreground: oklch(0.65 0.22 280);
|
--sidebar-accent-foreground: oklch(0.65 0.15 180);
|
||||||
--sidebar-border: oklch(0.34 0.02 278);
|
--sidebar-border: oklch(0.34 0.02 178);
|
||||||
--sidebar-ring: oklch(0.65 0.22 280);
|
--sidebar-ring: oklch(0.65 0.15 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
1
apps/ws-scrcpy
Submodule
1
apps/ws-scrcpy
Submodule
Submodule apps/ws-scrcpy added at ef273d97c6
229
dokploy-install.sh
Normal file
229
dokploy-install.sh
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
install_dokploy() {
|
||||||
|
if [ "$(id -u)" != "0" ]; then
|
||||||
|
echo "This script must be run as root" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if is Mac OS
|
||||||
|
if [ "$(uname)" = "Darwin" ]; then
|
||||||
|
echo "This script must be run on Linux" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if is running inside a container
|
||||||
|
if [ -f /.dockerenv ]; then
|
||||||
|
echo "This script must be run on Linux" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if something is running on port 80
|
||||||
|
if ss -tulnp | grep ':80 ' >/dev/null; then
|
||||||
|
echo "Error: something is already running on port 80" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if something is running on port 443
|
||||||
|
if ss -tulnp | grep ':443 ' >/dev/null; then
|
||||||
|
echo "Error: something is already running on port 443" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check if something is running on port 3000
|
||||||
|
if ss -tulnp | grep ':3000 ' >/dev/null; then
|
||||||
|
echo "Error: something is already running on port 3000" >&2
|
||||||
|
echo "Dokploy requires port 3000 to be available. Please stop any service using this port." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
command_exists() {
|
||||||
|
command -v "$@" > /dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
if command_exists docker; then
|
||||||
|
echo "Docker already installed"
|
||||||
|
else
|
||||||
|
curl -sSL https://get.docker.com | sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker swarm leave --force 2>/dev/null
|
||||||
|
|
||||||
|
get_ip() {
|
||||||
|
local ip=""
|
||||||
|
|
||||||
|
# Try IPv4 first
|
||||||
|
# First attempt: ifconfig.io
|
||||||
|
ip=$(curl -4s --connect-timeout 5 https://ifconfig.io 2>/dev/null)
|
||||||
|
|
||||||
|
# Second attempt: icanhazip.com
|
||||||
|
if [ -z "$ip" ]; then
|
||||||
|
ip=$(curl -4s --connect-timeout 5 https://icanhazip.com 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Third attempt: ipecho.net
|
||||||
|
if [ -z "$ip" ]; then
|
||||||
|
ip=$(curl -4s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If no IPv4, try IPv6
|
||||||
|
if [ -z "$ip" ]; then
|
||||||
|
# Try IPv6 with ifconfig.io
|
||||||
|
ip=$(curl -6s --connect-timeout 5 https://ifconfig.io 2>/dev/null)
|
||||||
|
|
||||||
|
# Try IPv6 with icanhazip.com
|
||||||
|
if [ -z "$ip" ]; then
|
||||||
|
ip=$(curl -6s --connect-timeout 5 https://icanhazip.com 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try IPv6 with ipecho.net
|
||||||
|
if [ -z "$ip" ]; then
|
||||||
|
ip=$(curl -6s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ip" ]; then
|
||||||
|
echo "Error: Could not determine server IP address automatically (neither IPv4 nor IPv6)." >&2
|
||||||
|
echo "Please set the ADVERTISE_ADDR environment variable manually." >&2
|
||||||
|
echo "Example: export ADVERTISE_ADDR=<your-server-ip>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
advertise_addr="${ADVERTISE_ADDR:-$(get_ip)}"
|
||||||
|
echo "Using advertise address: $advertise_addr"
|
||||||
|
|
||||||
|
docker swarm init \
|
||||||
|
--advertise-addr $advertise_addr \
|
||||||
|
--default-addr-pool 10.200.0.0/16 \
|
||||||
|
--default-addr-pool-mask-length 24
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Failed to initialize Docker Swarm" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Swarm initialized"
|
||||||
|
|
||||||
|
docker network rm -f dokploy-network 2>/dev/null
|
||||||
|
docker network create --driver overlay --attachable --subnet 10.201.0.0/16 dokploy-network
|
||||||
|
|
||||||
|
echo "Network created"
|
||||||
|
|
||||||
|
mkdir -p /etc/dokploy
|
||||||
|
|
||||||
|
chmod 777 /etc/dokploy
|
||||||
|
|
||||||
|
# Generate secure random password for Postgres
|
||||||
|
POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
|
||||||
|
|
||||||
|
# Store password as Docker Secret (encrypted and secure)
|
||||||
|
echo "$POSTGRES_PASSWORD" | docker secret create dokploy_postgres_password - 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Generated secure database credentials (stored in Docker Secrets)"
|
||||||
|
|
||||||
|
docker service create \
|
||||||
|
--name dokploy-postgres \
|
||||||
|
--constraint 'node.role==manager' \
|
||||||
|
--network dokploy-network \
|
||||||
|
--env POSTGRES_USER=dokploy \
|
||||||
|
--env POSTGRES_DB=dokploy \
|
||||||
|
--secret source=dokploy_postgres_password,target=/run/secrets/postgres_password \
|
||||||
|
--env POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password \
|
||||||
|
--mount type=volume,source=dokploy-postgres,target=/var/lib/postgresql/data \
|
||||||
|
postgres:16
|
||||||
|
|
||||||
|
docker service create \
|
||||||
|
--name dokploy-redis \
|
||||||
|
--constraint 'node.role==manager' \
|
||||||
|
--network dokploy-network \
|
||||||
|
--mount type=volume,source=dokploy-redis,target=/data \
|
||||||
|
redis:7
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
docker service create \
|
||||||
|
--name dokploy \
|
||||||
|
--replicas 1 \
|
||||||
|
--network dokploy-network \
|
||||||
|
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
|
||||||
|
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
|
||||||
|
--mount type=volume,source=dokploy,target=/root/.docker \
|
||||||
|
--secret source=dokploy_postgres_password,target=/run/secrets/postgres_password \
|
||||||
|
--publish published=3000,target=3000,mode=host \
|
||||||
|
--update-parallelism 1 \
|
||||||
|
--update-order stop-first \
|
||||||
|
--constraint 'node.role == manager' \
|
||||||
|
-e ADVERTISE_ADDR=$advertise_addr \
|
||||||
|
-e POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password \
|
||||||
|
dokploy/dokploy:latest
|
||||||
|
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name dokploy-traefik \
|
||||||
|
--restart always \
|
||||||
|
-v /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml \
|
||||||
|
-v /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock:ro \
|
||||||
|
-p 80:80/tcp \
|
||||||
|
-p 443:443/tcp \
|
||||||
|
-p 443:443/udp \
|
||||||
|
traefik:v3.6.7
|
||||||
|
|
||||||
|
docker network connect dokploy-network dokploy-traefik
|
||||||
|
|
||||||
|
|
||||||
|
# Optional: Use docker service create instead of docker run
|
||||||
|
# docker service create \
|
||||||
|
# --name dokploy-traefik \
|
||||||
|
# --constraint 'node.role==manager' \
|
||||||
|
# --network dokploy-network \
|
||||||
|
# --mount type=bind,source=/etc/dokploy/traefik/traefik.yml,target=/etc/traefik/traefik.yml \
|
||||||
|
# --mount type=bind,source=/etc/dokploy/traefik/dynamic,target=/etc/dokploy/traefik/dynamic \
|
||||||
|
# --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly \
|
||||||
|
# --publish mode=host,published=443,target=443 \
|
||||||
|
# --publish mode=host,published=80,target=80 \
|
||||||
|
# --publish mode=host,published=443,target=443,protocol=udp \
|
||||||
|
# traefik:v3.6.7
|
||||||
|
|
||||||
|
GREEN="\033[0;32m"
|
||||||
|
YELLOW="\033[1;33m"
|
||||||
|
BLUE="\033[0;34m"
|
||||||
|
NC="\033[0m" # No Color
|
||||||
|
|
||||||
|
format_ip_for_url() {
|
||||||
|
local ip="$1"
|
||||||
|
if echo "$ip" | grep -q ':'; then
|
||||||
|
# IPv6
|
||||||
|
echo "[${ip}]"
|
||||||
|
else
|
||||||
|
# IPv4
|
||||||
|
echo "${ip}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted_addr=$(format_ip_for_url "$advertise_addr")
|
||||||
|
echo ""
|
||||||
|
printf "${GREEN}Congratulations, Dokploy is installed!${NC}\n"
|
||||||
|
printf "${BLUE}Wait 15 seconds for the server to start${NC}\n"
|
||||||
|
printf "${YELLOW}Please go to http://${formatted_addr}:3000${NC}\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
update_dokploy() {
|
||||||
|
echo "Updating Dokploy..."
|
||||||
|
|
||||||
|
# Pull the latest image
|
||||||
|
docker pull dokploy/dokploy:latest
|
||||||
|
|
||||||
|
# Update the service
|
||||||
|
docker service update --image dokploy/dokploy:latest dokploy
|
||||||
|
|
||||||
|
echo "Dokploy has been updated to the latest version."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main script execution
|
||||||
|
if [ "$1" = "update" ]; then
|
||||||
|
update_dokploy
|
||||||
|
else
|
||||||
|
install_dokploy
|
||||||
|
fi
|
||||||
142
packages/db/migrations/0000_colorful_the_leader.sql
Normal file
142
packages/db/migrations/0000_colorful_the_leader.sql
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
CREATE TABLE "two_factor" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"secret" text NOT NULL,
|
||||||
|
"backup_codes" json,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "twofa_sessions" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"session_id" text NOT NULL,
|
||||||
|
"verification_token" text NOT NULL,
|
||||||
|
"code_used" text,
|
||||||
|
"status" varchar(16) NOT NULL,
|
||||||
|
"attempts" integer DEFAULT 0 NOT NULL,
|
||||||
|
"max_attempts" integer DEFAULT 5 NOT NULL,
|
||||||
|
"verified_at" timestamp,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"ip_address" text DEFAULT '',
|
||||||
|
"user_agent" text DEFAULT '',
|
||||||
|
CONSTRAINT "twofa_sessions_verification_token_unique" UNIQUE("verification_token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"account_id" text NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"access_token" text,
|
||||||
|
"refresh_token" text,
|
||||||
|
"id_token" text,
|
||||||
|
"access_token_expires_at" timestamp,
|
||||||
|
"refresh_token_expires_at" timestamp,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"email_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"username" text,
|
||||||
|
"display_username" text,
|
||||||
|
"role" text,
|
||||||
|
"banned" boolean DEFAULT false,
|
||||||
|
"ban_reason" text,
|
||||||
|
"ban_expires" timestamp,
|
||||||
|
"onboarding_done" boolean DEFAULT false,
|
||||||
|
"last2_fa_verified_at" timestamp,
|
||||||
|
"parent_id" text,
|
||||||
|
CONSTRAINT "user_email_unique" UNIQUE("email"),
|
||||||
|
CONSTRAINT "user_username_unique" UNIQUE("username")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "device" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"version" text NOT NULL,
|
||||||
|
"status" varchar(16) DEFAULT 'offline' NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT false NOT NULL,
|
||||||
|
"container_id" text,
|
||||||
|
"host" text NOT NULL,
|
||||||
|
"ws_port" text,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "notifications" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"body" text NOT NULL,
|
||||||
|
"priority" varchar(12) DEFAULT 'normal' NOT NULL,
|
||||||
|
"type" varchar(12) NOT NULL,
|
||||||
|
"category" varchar(64),
|
||||||
|
"is_read" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_archived" boolean DEFAULT false NOT NULL,
|
||||||
|
"action_url" text,
|
||||||
|
"action_type" varchar(16),
|
||||||
|
"action_data" json,
|
||||||
|
"icon" varchar(64),
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"sent_at" timestamp NOT NULL,
|
||||||
|
"read_at" timestamp,
|
||||||
|
"expires_at" timestamp,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "link" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"status" varchar(16) DEFAULT 'active' NOT NULL,
|
||||||
|
"linked_device_id" integer,
|
||||||
|
"expires_at" timestamp,
|
||||||
|
"last_accessed_at" timestamp,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL,
|
||||||
|
CONSTRAINT "link_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "task" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"type" varchar(32) NOT NULL,
|
||||||
|
"status" varchar(16) NOT NULL,
|
||||||
|
"progress" integer DEFAULT 0 NOT NULL,
|
||||||
|
"payload" json,
|
||||||
|
"result" json,
|
||||||
|
"error" json,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"resource_id" text NOT NULL,
|
||||||
|
"started_at" timestamp,
|
||||||
|
"completed_at" timestamp,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "twofa_sessions" ADD CONSTRAINT "twofa_sessions_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "link" ADD CONSTRAINT "link_linked_device_id_device_id_fk" FOREIGN KEY ("linked_device_id") REFERENCES "public"."device"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "task" ADD CONSTRAINT "task_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
|
||||||
920
packages/db/migrations/meta/0000_snapshot.json
Normal file
920
packages/db/migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,920 @@
|
|||||||
|
{
|
||||||
|
"id": "c0dc4466-4211-49aa-97b0-917cc0c30871",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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'"
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/db/migrations/meta/_journal.json
Normal file
13
packages/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774650657798,
|
||||||
|
"tag": "0000_colorful_the_leader",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -19,10 +19,6 @@ export const settingsSchema = v.object({
|
|||||||
debugKey: v.string(),
|
debugKey: v.string(),
|
||||||
|
|
||||||
processorApiUrl: v.string(),
|
processorApiUrl: v.string(),
|
||||||
appBuilderApiUrl: v.string(),
|
|
||||||
appBuilderAssetsPublicUrl: v.string(),
|
|
||||||
clientDownloadedApkName: v.string(),
|
|
||||||
mobileAppApiUrl: v.string(),
|
|
||||||
|
|
||||||
betterAuthUrl: v.string(),
|
betterAuthUrl: v.string(),
|
||||||
betterAuthSecret: v.string(),
|
betterAuthSecret: v.string(),
|
||||||
@@ -108,11 +104,6 @@ function loadSettings(): Settings {
|
|||||||
"APP_BUILDER_ASSETS_PUBLIC_URL",
|
"APP_BUILDER_ASSETS_PUBLIC_URL",
|
||||||
"http://localhost:3001",
|
"http://localhost:3001",
|
||||||
),
|
),
|
||||||
clientDownloadedApkName: getEnv(
|
|
||||||
"CLIENT_DOWNLOADED_APK_NAME",
|
|
||||||
"illusory-client.apk",
|
|
||||||
),
|
|
||||||
mobileAppApiUrl: getEnv("MOBILE_APP_API_URL"),
|
|
||||||
|
|
||||||
betterAuthUrl: getEnv("BETTER_AUTH_URL"),
|
betterAuthUrl: getEnv("BETTER_AUTH_URL"),
|
||||||
betterAuthSecret: getEnv("BETTER_AUTH_SECRET"),
|
betterAuthSecret: getEnv("BETTER_AUTH_SECRET"),
|
||||||
|
|||||||
Reference in New Issue
Block a user