mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge branch 'canary' of github.com:imran-vz/dokploy into feat/quick-service-switcher
This commit is contained in:
@@ -1,2 +1,11 @@
|
|||||||
LEMON_SQUEEZY_API_KEY=""
|
LEMON_SQUEEZY_API_KEY=""
|
||||||
LEMON_SQUEEZY_STORE_ID=""
|
LEMON_SQUEEZY_STORE_ID=""
|
||||||
|
|
||||||
|
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
|
||||||
|
# INNGEST_BASE_URL="http://localhost:8288"
|
||||||
|
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
|
||||||
|
# INNGEST_SIGNING_KEY="your-signing-key"
|
||||||
|
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
|
||||||
|
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
|
||||||
|
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
|
||||||
|
# INNGEST_JOBS_MAX_EVENTS=100
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type DeployJob,
|
type DeployJob,
|
||||||
deployJobSchema,
|
deployJobSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
import { fetchDeploymentJobs } from "./service.js";
|
||||||
import { deploy } from "./utils.js";
|
import { deploy } from "./utils.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("error", error);
|
|
||||||
logger.error("Failed to send deployment event", error);
|
logger.error("Failed to send deployment event", error);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
@@ -176,6 +176,29 @@ app.get("/health", async (c) => {
|
|||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
|
||||||
|
app.get("/jobs", async (c) => {
|
||||||
|
const serverId = c.req.query("serverId");
|
||||||
|
if (!serverId) {
|
||||||
|
return c.json({ message: "serverId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await fetchDeploymentJobs(serverId);
|
||||||
|
return c.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes("INNGEST_BASE_URL")) {
|
||||||
|
return c.json(
|
||||||
|
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
|
||||||
|
return c.json([], 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Serve Inngest functions endpoint
|
// Serve Inngest functions endpoint
|
||||||
app.on(
|
app.on(
|
||||||
["GET", "POST", "PUT"],
|
["GET", "POST", "PUT"],
|
||||||
|
|||||||
239
apps/api/src/service.ts
Normal file
239
apps/api/src/service.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
|
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
|
||||||
|
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_EVENTS = 500;
|
||||||
|
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
|
||||||
|
|
||||||
|
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
|
||||||
|
type InngestEventRow = {
|
||||||
|
internal_id?: string;
|
||||||
|
accountID?: string;
|
||||||
|
environmentID?: string;
|
||||||
|
source?: string;
|
||||||
|
sourceID?: string | null;
|
||||||
|
/** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */
|
||||||
|
receivedAt?: string;
|
||||||
|
received_at?: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
user?: unknown;
|
||||||
|
ts: number;
|
||||||
|
v?: string | null;
|
||||||
|
metadata?: {
|
||||||
|
fetchedAt: string;
|
||||||
|
cachedUntil: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */
|
||||||
|
type InngestRun = {
|
||||||
|
run_id: string;
|
||||||
|
event_id: string;
|
||||||
|
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
|
||||||
|
run_started_at?: string;
|
||||||
|
ended_at?: string | null;
|
||||||
|
output?: unknown;
|
||||||
|
// dev server / API may use different casing
|
||||||
|
run_started_at_ms?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
|
||||||
|
return ev.receivedAt ?? ev.received_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map Inngest run status to BullMQ-style state for the UI */
|
||||||
|
function runStatusToState(
|
||||||
|
status: string,
|
||||||
|
): "pending" | "active" | "completed" | "failed" | "cancelled" {
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
if (s === "running") return "active";
|
||||||
|
if (s === "completed") return "completed";
|
||||||
|
if (s === "failed") return "failed";
|
||||||
|
if (s === "cancelled") return "cancelled";
|
||||||
|
if (s === "queued") return "pending";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchInngestEvents = async () => {
|
||||||
|
const maxEvents = MAX_EVENTS;
|
||||||
|
const all: InngestEventRow[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ limit: "100" });
|
||||||
|
if (cursor) {
|
||||||
|
params.set("cursor", cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${signingKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn("Inngest API error", {
|
||||||
|
status: res.status,
|
||||||
|
body: await res.text(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
data?: InngestEventRow[];
|
||||||
|
cursor?: string;
|
||||||
|
nextCursor?: string;
|
||||||
|
};
|
||||||
|
const data = Array.isArray(body.data) ? body.data : [];
|
||||||
|
all.push(...data);
|
||||||
|
|
||||||
|
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
|
||||||
|
const nextCursor =
|
||||||
|
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
|
||||||
|
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
|
||||||
|
cursor = hasMore ? nextCursor : undefined;
|
||||||
|
} while (cursor);
|
||||||
|
|
||||||
|
return all.slice(0, maxEvents);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */
|
||||||
|
export const fetchInngestRunsForEvent = async (
|
||||||
|
eventId: string,
|
||||||
|
): Promise<InngestRun[]> => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${signingKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn("Inngest runs API error", {
|
||||||
|
eventId,
|
||||||
|
status: res.status,
|
||||||
|
body: await res.text(),
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { data?: InngestRun[] };
|
||||||
|
return Array.isArray(body.data) ? body.data : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One row for the queue UI (BullMQ-compatible shape) */
|
||||||
|
export type DeploymentJobRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
timestamp: number;
|
||||||
|
processedOn?: number;
|
||||||
|
finishedOn?: number;
|
||||||
|
failedReason?: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
|
||||||
|
function buildDeploymentRowsFromRuns(
|
||||||
|
events: InngestEventRow[],
|
||||||
|
runsByEventId: Map<string, InngestRun[]>,
|
||||||
|
serverId: string,
|
||||||
|
): DeploymentJobRow[] {
|
||||||
|
const requested = events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name === "deployment/requested" &&
|
||||||
|
(e.data as Record<string, unknown>)?.serverId === serverId,
|
||||||
|
);
|
||||||
|
const rows: DeploymentJobRow[] = [];
|
||||||
|
|
||||||
|
for (const ev of requested) {
|
||||||
|
const data = (ev.data ?? {}) as Record<string, unknown>;
|
||||||
|
const runs = runsByEventId.get(ev.id) ?? [];
|
||||||
|
|
||||||
|
if (runs.length === 0) {
|
||||||
|
// Queued: event received but no run yet
|
||||||
|
rows.push({
|
||||||
|
id: ev.id,
|
||||||
|
name: ev.name,
|
||||||
|
data,
|
||||||
|
timestamp: ev.ts,
|
||||||
|
processedOn: ev.ts,
|
||||||
|
finishedOn: undefined,
|
||||||
|
failedReason: undefined,
|
||||||
|
state: "pending",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const run of runs) {
|
||||||
|
const state = runStatusToState(run.status);
|
||||||
|
const runStartedMs =
|
||||||
|
run.run_started_at_ms ??
|
||||||
|
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
|
||||||
|
const endedMs = run.ended_at
|
||||||
|
? new Date(run.ended_at).getTime()
|
||||||
|
: undefined;
|
||||||
|
const failedReason =
|
||||||
|
state === "failed" &&
|
||||||
|
run.output &&
|
||||||
|
typeof run.output === "object" &&
|
||||||
|
"error" in run.output
|
||||||
|
? String((run.output as { error?: unknown }).error)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: run.run_id,
|
||||||
|
name: ev.name,
|
||||||
|
data,
|
||||||
|
timestamp: runStartedMs,
|
||||||
|
processedOn: runStartedMs,
|
||||||
|
finishedOn:
|
||||||
|
state === "completed" || state === "failed" || state === "cancelled"
|
||||||
|
? endedMs
|
||||||
|
: undefined,
|
||||||
|
failedReason,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
|
||||||
|
export const fetchDeploymentJobs = async (
|
||||||
|
serverId: string,
|
||||||
|
): Promise<DeploymentJobRow[]> => {
|
||||||
|
if (!signingKey) {
|
||||||
|
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await fetchInngestEvents();
|
||||||
|
|
||||||
|
const requestedForServer = events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name === "deployment/requested" &&
|
||||||
|
(e.data as Record<string, unknown>)?.serverId === serverId,
|
||||||
|
);
|
||||||
|
// Limit to avoid too many run fetches
|
||||||
|
const toFetch = requestedForServer.slice(0, 50);
|
||||||
|
const runsByEventId = new Map<string, InngestRun[]>();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
toFetch.map(async (ev) => {
|
||||||
|
const runs = await fetchInngestRunsForEvent(ev.id);
|
||||||
|
runsByEventId.set(ev.id, runs);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
|
||||||
|
};
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import type { DeployJob } from "./schema";
|
import type { DeployJob } from "./schema.js";
|
||||||
|
|
||||||
export const deploy = async (job: DeployJob) => {
|
export const deploy = async (job: DeployJob) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ColumnFiltersState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type PaginationState,
|
||||||
|
type SortingState,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
import {
|
||||||
|
ArrowUpDown,
|
||||||
|
Boxes,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Rocket,
|
||||||
|
Server,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { AppRouter } from "@/server/api/root";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
type DeploymentRow =
|
||||||
|
inferRouterOutputs<AppRouter>["deployment"]["allCentralized"][number];
|
||||||
|
|
||||||
|
const statusVariants: Record<
|
||||||
|
string,
|
||||||
|
| "default"
|
||||||
|
| "secondary"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "yellow"
|
||||||
|
| "green"
|
||||||
|
| "red"
|
||||||
|
> = {
|
||||||
|
running: "yellow",
|
||||||
|
done: "green",
|
||||||
|
error: "red",
|
||||||
|
cancelled: "outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getServiceInfo(d: DeploymentRow) {
|
||||||
|
const app = d.application;
|
||||||
|
const comp = d.compose;
|
||||||
|
if (app?.environment?.project && app.environment) {
|
||||||
|
return {
|
||||||
|
type: "Application" as const,
|
||||||
|
name: app.name,
|
||||||
|
projectId: app.environment.project.projectId,
|
||||||
|
environmentId: app.environment.environmentId,
|
||||||
|
projectName: app.environment.project.name,
|
||||||
|
environmentName: app.environment.name,
|
||||||
|
serviceId: app.applicationId,
|
||||||
|
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (comp?.environment?.project && comp.environment) {
|
||||||
|
return {
|
||||||
|
type: "Compose" as const,
|
||||||
|
name: comp.name,
|
||||||
|
projectId: comp.environment.project.projectId,
|
||||||
|
environmentId: comp.environment.environmentId,
|
||||||
|
projectName: comp.environment.project.name,
|
||||||
|
environmentName: comp.environment.name,
|
||||||
|
serviceId: comp.composeId,
|
||||||
|
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShowDeploymentsTable() {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
|
{ id: "createdAt", desc: true },
|
||||||
|
]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: deploymentsList, isLoading } =
|
||||||
|
api.deployment.allCentralized.useQuery(undefined, {
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!deploymentsList) return [];
|
||||||
|
let list = deploymentsList;
|
||||||
|
if (statusFilter !== "all") {
|
||||||
|
list = list.filter((d) => d.status === statusFilter);
|
||||||
|
}
|
||||||
|
if (typeFilter === "application") {
|
||||||
|
list = list.filter((d) => d.applicationId != null);
|
||||||
|
} else if (typeFilter === "compose") {
|
||||||
|
list = list.filter((d) => d.composeId != null);
|
||||||
|
}
|
||||||
|
if (globalFilter.trim()) {
|
||||||
|
const q = globalFilter.toLowerCase();
|
||||||
|
list = list.filter((d) => {
|
||||||
|
const info = getServiceInfo(d);
|
||||||
|
const serverName =
|
||||||
|
d.server?.name ??
|
||||||
|
d.application?.server?.name ??
|
||||||
|
d.compose?.server?.name ??
|
||||||
|
"";
|
||||||
|
const buildServerName =
|
||||||
|
d.buildServer?.name ?? d.application?.buildServer?.name ?? "";
|
||||||
|
if (!info) return false;
|
||||||
|
return (
|
||||||
|
info.name.toLowerCase().includes(q) ||
|
||||||
|
info.projectName.toLowerCase().includes(q) ||
|
||||||
|
info.environmentName.toLowerCase().includes(q) ||
|
||||||
|
(d.title?.toLowerCase().includes(q) ?? false) ||
|
||||||
|
serverName.toLowerCase().includes(q) ||
|
||||||
|
buildServerName.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [deploymentsList, statusFilter, typeFilter, globalFilter]);
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "serviceName",
|
||||||
|
accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "",
|
||||||
|
header: ({
|
||||||
|
column,
|
||||||
|
}: {
|
||||||
|
column: {
|
||||||
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
|
toggleSorting: (asc: boolean) => void;
|
||||||
|
};
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Service
|
||||||
|
<ArrowUpDown className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||||
|
const info = getServiceInfo(row.original);
|
||||||
|
if (!info) return <span className="text-muted-foreground">—</span>;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{info.type === "Application" ? (
|
||||||
|
<Rocket className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Boxes className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="font-medium truncate">{info.name}</span>
|
||||||
|
<Badge variant="outline" className="w-fit text-[10px]">
|
||||||
|
{info.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "projectName",
|
||||||
|
accessorFn: (row: DeploymentRow) =>
|
||||||
|
getServiceInfo(row)?.projectName ?? "",
|
||||||
|
header: ({
|
||||||
|
column,
|
||||||
|
}: {
|
||||||
|
column: {
|
||||||
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
|
toggleSorting: (asc: boolean) => void;
|
||||||
|
};
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Project
|
||||||
|
<ArrowUpDown className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||||
|
const info = getServiceInfo(row.original);
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{info?.projectName ?? "—"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "environmentName",
|
||||||
|
accessorFn: (row: DeploymentRow) =>
|
||||||
|
getServiceInfo(row)?.environmentName ?? "",
|
||||||
|
header: ({
|
||||||
|
column,
|
||||||
|
}: {
|
||||||
|
column: {
|
||||||
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
|
toggleSorting: (asc: boolean) => void;
|
||||||
|
};
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Environment
|
||||||
|
<ArrowUpDown className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||||
|
const info = getServiceInfo(row.original);
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{info?.environmentName ?? "—"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "serverName",
|
||||||
|
accessorFn: (row: DeploymentRow) =>
|
||||||
|
row.server?.name ??
|
||||||
|
row.application?.server?.name ??
|
||||||
|
row.compose?.server?.name ??
|
||||||
|
"",
|
||||||
|
header: ({
|
||||||
|
column,
|
||||||
|
}: {
|
||||||
|
column: {
|
||||||
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
|
toggleSorting: (asc: boolean) => void;
|
||||||
|
};
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Server
|
||||||
|
<ArrowUpDown className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||||
|
const d = row.original;
|
||||||
|
const serverName =
|
||||||
|
d.server?.name ??
|
||||||
|
d.application?.server?.name ??
|
||||||
|
d.compose?.server?.name ??
|
||||||
|
null;
|
||||||
|
const serverType =
|
||||||
|
d.server?.serverType ??
|
||||||
|
d.application?.server?.serverType ??
|
||||||
|
d.compose?.server?.serverType ??
|
||||||
|
null;
|
||||||
|
const buildServerName =
|
||||||
|
d.buildServer?.name ?? d.application?.buildServer?.name ?? null;
|
||||||
|
const buildServerType =
|
||||||
|
d.buildServer?.serverType ??
|
||||||
|
d.application?.buildServer?.serverType ??
|
||||||
|
null;
|
||||||
|
const showBuild =
|
||||||
|
buildServerName != null && buildServerName !== serverName;
|
||||||
|
if (!serverName && !showBuild) {
|
||||||
|
return <span className="text-muted-foreground">—</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5 text-sm">
|
||||||
|
{serverName && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Server className="size-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<span className="truncate">{serverName}</span>
|
||||||
|
{serverType && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{serverType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showBuild && buildServerName && (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground flex-wrap">
|
||||||
|
<span className="text-[10px]">Build:</span>
|
||||||
|
<span className="truncate text-xs">{buildServerName}</span>
|
||||||
|
{buildServerType && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
{buildServerType}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: ({
|
||||||
|
column,
|
||||||
|
}: {
|
||||||
|
column: {
|
||||||
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
|
toggleSorting: (asc: boolean) => void;
|
||||||
|
};
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
<ArrowUpDown className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: { original: DeploymentRow } }) => (
|
||||||
|
<span className="text-sm truncate max-w-[200px] block">
|
||||||
|
{row.original.title || "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: ({
|
||||||
|
column,
|
||||||
|
}: {
|
||||||
|
column: {
|
||||||
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
|
toggleSorting: (asc: boolean) => void;
|
||||||
|
};
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
<ArrowUpDown className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||||
|
const status = row.original.status ?? "running";
|
||||||
|
return (
|
||||||
|
<Badge variant={statusVariants[status] ?? "secondary"}>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({
|
||||||
|
column,
|
||||||
|
}: {
|
||||||
|
column: {
|
||||||
|
getIsSorted: () => false | "asc" | "desc";
|
||||||
|
toggleSorting: (asc: boolean) => void;
|
||||||
|
};
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="-ml-3 h-8"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
Created
|
||||||
|
<ArrowUpDown className="ml-2 size-4" />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: { original: DeploymentRow } }) => (
|
||||||
|
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
|
{row.original.createdAt
|
||||||
|
? new Date(row.original.createdAt).toLocaleString()
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
id: "actions",
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||||
|
const info = getServiceInfo(row.original);
|
||||||
|
if (!info) return null;
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={info.href} className="gap-1">
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
Open
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredData,
|
||||||
|
columns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
globalFilter,
|
||||||
|
pagination,
|
||||||
|
},
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name, project, environment, server..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectItem value="running">Running</SelectItem>
|
||||||
|
<SelectItem value="done">Done</SelectItem>
|
||||||
|
<SelectItem value="error">Error</SelectItem>
|
||||||
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All types</SelectItem>
|
||||||
|
<SelectItem value="application">Application</SelectItem>
|
||||||
|
<SelectItem value="compose">Compose</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="px-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>Loading deployments...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className=" text-center"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
||||||
|
<Rocket className="size-8" />
|
||||||
|
<p className="font-medium">No deployments found</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Deployments from applications and compose will
|
||||||
|
appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
Rows per page
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={String(pagination.pageSize)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setPagination((p) => ({
|
||||||
|
...p,
|
||||||
|
pageSize: Number(value),
|
||||||
|
pageIndex: 0,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 25, 50, 100].map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
Showing{" "}
|
||||||
|
{filteredData.length === 0
|
||||||
|
? 0
|
||||||
|
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
||||||
|
to{" "}
|
||||||
|
{Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
filteredData.length,
|
||||||
|
)}{" "}
|
||||||
|
of {filteredData.length} entries
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import type { AppRouter } from "@/server/api/root";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
type QueueRow =
|
||||||
|
inferRouterOutputs<AppRouter>["deployment"]["queueList"][number];
|
||||||
|
|
||||||
|
const stateVariants: Record<
|
||||||
|
string,
|
||||||
|
| "default"
|
||||||
|
| "secondary"
|
||||||
|
| "destructive"
|
||||||
|
| "outline"
|
||||||
|
| "yellow"
|
||||||
|
| "green"
|
||||||
|
| "red"
|
||||||
|
> = {
|
||||||
|
pending: "secondary",
|
||||||
|
waiting: "secondary",
|
||||||
|
active: "yellow",
|
||||||
|
delayed: "outline",
|
||||||
|
completed: "green",
|
||||||
|
failed: "destructive",
|
||||||
|
cancelled: "outline",
|
||||||
|
paused: "outline",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTs(ts?: number): string {
|
||||||
|
if (ts == null) return "—";
|
||||||
|
const d = new Date(ts);
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJobLabel(row: QueueRow): string {
|
||||||
|
const d = row.data as {
|
||||||
|
applicationType?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
composeId?: string;
|
||||||
|
previewDeploymentId?: string;
|
||||||
|
titleLog?: string;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
if (!d) return String(row.id);
|
||||||
|
const type = d.applicationType ?? "job";
|
||||||
|
const title = d.titleLog ?? "";
|
||||||
|
if (title) return title;
|
||||||
|
if (d.applicationId) return `Application ${d.applicationId.slice(0, 8)}…`;
|
||||||
|
if (d.composeId) return `Compose ${d.composeId.slice(0, 8)}…`;
|
||||||
|
if (d.previewDeploymentId)
|
||||||
|
return `Preview ${d.previewDeploymentId.slice(0, 8)}…`;
|
||||||
|
return `${type} ${String(row.id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShowQueueTable(props: { embedded?: boolean }) {
|
||||||
|
const { embedded: _embedded = false } = props;
|
||||||
|
const { data: queueList, isLoading } = api.deployment.queueList.useQuery(
|
||||||
|
undefined,
|
||||||
|
{ refetchInterval: 3000 },
|
||||||
|
);
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const {
|
||||||
|
mutateAsync: cancelApplicationDeployment,
|
||||||
|
isPending: isCancellingApp,
|
||||||
|
} = api.application.cancelDeployment.useMutation({
|
||||||
|
onSuccess: () => void utils.deployment.queueList.invalidate(),
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
mutateAsync: cancelComposeDeployment,
|
||||||
|
isPending: isCancellingCompose,
|
||||||
|
} = api.compose.cancelDeployment.useMutation({
|
||||||
|
onSuccess: () => void utils.deployment.queueList.invalidate(),
|
||||||
|
});
|
||||||
|
const isCancelling = isCancellingApp || isCancellingCompose;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex gap-4 w-full items-center justify-center min-h-[30vh] text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>Loading queue...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Job ID</TableHead>
|
||||||
|
<TableHead>Label</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>State</TableHead>
|
||||||
|
<TableHead>Added</TableHead>
|
||||||
|
<TableHead>Processed</TableHead>
|
||||||
|
<TableHead>Finished</TableHead>
|
||||||
|
<TableHead>Error</TableHead>
|
||||||
|
<TableHead className="w-[100px]">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{queueList?.length ? (
|
||||||
|
queueList.map((row) => {
|
||||||
|
const d = row.data as Record<string, unknown>;
|
||||||
|
const appType = d?.applicationType as string | undefined;
|
||||||
|
const pathInfo = row.servicePath;
|
||||||
|
const hasLink = pathInfo?.href != null;
|
||||||
|
return (
|
||||||
|
<TableRow key={String(row.id)}>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{String(row.id)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate">
|
||||||
|
{getJobLabel(row)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{appType ?? row.name ?? "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={stateVariants[row.state] ?? "outline"}>
|
||||||
|
{row.state}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{formatTs(row.timestamp)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{formatTs(row.processedOn)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{formatTs(row.finishedOn)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[180px] truncate text-xs text-destructive">
|
||||||
|
{row.failedReason ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{hasLink ? (
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={pathInfo!.href!}>
|
||||||
|
<ArrowRight className="size-4 mr-1" />
|
||||||
|
Service
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCloud &&
|
||||||
|
row.state === "active" &&
|
||||||
|
(d?.applicationId != null ||
|
||||||
|
d?.composeId != null) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
disabled={isCancelling}
|
||||||
|
onClick={() => {
|
||||||
|
const appId =
|
||||||
|
typeof d.applicationId === "string"
|
||||||
|
? d.applicationId
|
||||||
|
: undefined;
|
||||||
|
const compId =
|
||||||
|
typeof d.composeId === "string"
|
||||||
|
? d.composeId
|
||||||
|
: undefined;
|
||||||
|
if (appId) {
|
||||||
|
void cancelApplicationDeployment({
|
||||||
|
applicationId: appId,
|
||||||
|
});
|
||||||
|
} else if (compId) {
|
||||||
|
void cancelComposeDeployment({
|
||||||
|
composeId: compId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XCircle className="size-4 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="text-center py-12">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground min-h-[30vh]">
|
||||||
|
<ListTodo className="size-8" />
|
||||||
|
<p className="font-medium">Queue is empty</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Deployment jobs will appear here when they are queued.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const organizationSchema = z.object({
|
const organizationSchema = z.object({
|
||||||
@@ -55,8 +54,6 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
const { mutateAsync, isPending } = organizationId
|
const { mutateAsync, isPending } = organizationId
|
||||||
? api.organization.update.useMutation()
|
? api.organization.update.useMutation()
|
||||||
: api.organization.create.useMutation();
|
: api.organization.create.useMutation();
|
||||||
const { refetch: refetchActiveOrganization } =
|
|
||||||
authClient.useActiveOrganization();
|
|
||||||
|
|
||||||
const form = useForm<OrganizationFormValues>({
|
const form = useForm<OrganizationFormValues>({
|
||||||
resolver: zodResolver(organizationSchema),
|
resolver: zodResolver(organizationSchema),
|
||||||
@@ -89,7 +86,7 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
utils.organization.all.invalidate();
|
utils.organization.all.invalidate();
|
||||||
if (organizationId) {
|
if (organizationId) {
|
||||||
utils.organization.one.invalidate({ organizationId });
|
utils.organization.one.invalidate({ organizationId });
|
||||||
refetchActiveOrganization();
|
utils.organization.active.invalidate();
|
||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import type { LogEntry } from "./show-requests";
|
import type { LogEntry } from "./show-requests";
|
||||||
|
|
||||||
export const getStatusColor = (status: number) => {
|
export const getStatusColor = (status: number) => {
|
||||||
|
if (status === 0) {
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
if (status >= 100 && status < 200) {
|
if (status >= 100 && status < 200) {
|
||||||
return "outline";
|
return "outline";
|
||||||
}
|
}
|
||||||
@@ -21,6 +24,24 @@ export const getStatusColor = (status: number) => {
|
|||||||
return "destructive";
|
return "destructive";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatStatusLabel = (status: number) => {
|
||||||
|
if (status === 0) {
|
||||||
|
return "N/A";
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (nanos: number) => {
|
||||||
|
const ms = nanos / 1000000;
|
||||||
|
if (ms < 1) {
|
||||||
|
return `${(nanos / 1000).toFixed(2)} µs`;
|
||||||
|
}
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${ms.toFixed(2)} ms`;
|
||||||
|
}
|
||||||
|
return `${(ms / 1000).toFixed(2)} s`;
|
||||||
|
};
|
||||||
|
|
||||||
export const columns: ColumnDef<LogEntry>[] = [
|
export const columns: ColumnDef<LogEntry>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "level",
|
accessorKey: "level",
|
||||||
@@ -59,10 +80,10 @@ export const columns: ColumnDef<LogEntry>[] = [
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-3 w-full">
|
<div className="flex flex-row gap-3 w-full">
|
||||||
<Badge variant={getStatusColor(log.OriginStatus)}>
|
<Badge variant={getStatusColor(log.OriginStatus)}>
|
||||||
Status: {log.OriginStatus}
|
Status: {formatStatusLabel(log.OriginStatus)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={"secondary"}>
|
<Badge variant={"secondary"}>
|
||||||
Exec Time: {`${log.Duration / 1000000000}s`}
|
Exec Time: {formatDuration(log.Duration)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant={"secondary"}>IP: {log.ClientAddr}</Badge>
|
<Badge variant={"secondary"}>IP: {log.ClientAddr}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,7 +152,15 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
|||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(value, null, 2);
|
||||||
}
|
}
|
||||||
if (key === "Duration" || key === "OriginDuration" || key === "Overhead") {
|
if (key === "Duration" || key === "OriginDuration" || key === "Overhead") {
|
||||||
return `${value / 1000000000} s`;
|
const nanos = Number(value);
|
||||||
|
const ms = nanos / 1000000;
|
||||||
|
if (ms < 1) {
|
||||||
|
return `${(nanos / 1000).toFixed(2)} µs`;
|
||||||
|
}
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${ms.toFixed(2)} ms`;
|
||||||
|
}
|
||||||
|
return `${(ms / 1000).toFixed(2)} s`;
|
||||||
}
|
}
|
||||||
if (key === "level") {
|
if (key === "level") {
|
||||||
return <Badge variant="secondary">{value}</Badge>;
|
return <Badge variant="secondary">{value}</Badge>;
|
||||||
@@ -161,7 +169,11 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
|
|||||||
return <Badge variant="outline">{value}</Badge>;
|
return <Badge variant="outline">{value}</Badge>;
|
||||||
}
|
}
|
||||||
if (key === "DownstreamStatus" || key === "OriginStatus") {
|
if (key === "DownstreamStatus" || key === "OriginStatus") {
|
||||||
return <Badge variant={getStatusColor(value)}>{value}</Badge>;
|
const num = Number(value);
|
||||||
|
if (num === 0) {
|
||||||
|
return <Badge variant="secondary">N/A</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge variant={getStatusColor(num)}>{value}</Badge>;
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -174,6 +174,14 @@ export const SearchCommand = () => {
|
|||||||
>
|
>
|
||||||
Projects
|
Projects
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
router.push("/dashboard/deployments");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Deployments
|
||||||
|
</CommandItem>
|
||||||
{!isCloud && (
|
{!isCloud && (
|
||||||
<>
|
<>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
export const AddGithubProvider = () => {
|
export const AddGithubProvider = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||||
|
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
const { data } = api.user.get.useQuery();
|
const { data } = api.user.get.useQuery();
|
||||||
const [manifest, setManifest] = useState("");
|
const [manifest, setManifest] = useState("");
|
||||||
@@ -52,7 +53,7 @@ export const AddGithubProvider = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setManifest(manifest);
|
setManifest(manifest);
|
||||||
}, [data?.id, activeOrganization?.id, session?.user?.id]);
|
}, [activeOrganization?.id, session?.user?.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
@@ -131,11 +132,7 @@ export const AddGithubProvider = () => {
|
|||||||
Unsure if you already have an app?
|
Unsure if you already have an app?
|
||||||
</a>
|
</a>
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={isOrganization && organizationName.length < 1}
|
||||||
(isOrganization && organizationName.length < 1) ||
|
|
||||||
!activeOrganization?.id ||
|
|
||||||
!session?.user?.id
|
|
||||||
}
|
|
||||||
type="submit"
|
type="submit"
|
||||||
className="self-end"
|
className="self-end"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const AddInvitation = () => {
|
|||||||
api.notification.getEmailProviders.useQuery();
|
api.notification.getEmailProviders.useQuery();
|
||||||
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||||
|
|
||||||
const form = useForm<AddInvitation>({
|
const form = useForm<AddInvitation>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Package,
|
Package,
|
||||||
PieChart,
|
PieChart,
|
||||||
|
Rocket,
|
||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Star,
|
Star,
|
||||||
@@ -145,6 +146,12 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/projects",
|
url: "/dashboard/projects",
|
||||||
icon: Folder,
|
icon: Folder,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
isSingle: true,
|
||||||
|
title: "Deployments",
|
||||||
|
url: "/dashboard/deployments",
|
||||||
|
icon: Rocket,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
title: "Monitoring",
|
title: "Monitoring",
|
||||||
@@ -550,8 +557,7 @@ function SidebarLogo() {
|
|||||||
const { mutateAsync: setDefaultOrganization, isPending: isSettingDefault } =
|
const { mutateAsync: setDefaultOrganization, isPending: isSettingDefault } =
|
||||||
api.organization.setDefault.useMutation();
|
api.organization.setDefault.useMutation();
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||||
const _utils = api.useUtils();
|
|
||||||
|
|
||||||
const { data: invitations, refetch: refetchInvitations } =
|
const { data: invitations, refetch: refetchInvitations } =
|
||||||
api.user.getInvitations.useQuery();
|
api.user.getInvitations.useQuery();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Command = React.forwardRef<
|
|||||||
<CommandPrimitive
|
<CommandPrimitive
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground p-px",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -44,7 +44,7 @@ const CommandInput = React.forwardRef<
|
|||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 focus-visible:ring-inset",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.28.2",
|
"version": "v0.28.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
94
apps/dokploy/pages/dashboard/deployments.tsx
Normal file
94
apps/dokploy/pages/dashboard/deployments.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
|
import { Rocket } from "lucide-react";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
||||||
|
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
const TAB_VALUES = ["deployments", "queue"] as const;
|
||||||
|
type TabValue = (typeof TAB_VALUES)[number];
|
||||||
|
|
||||||
|
function isValidTab(t: string): t is TabValue {
|
||||||
|
return TAB_VALUES.includes(t as TabValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeploymentsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const tab =
|
||||||
|
router.query.tab && isValidTab(router.query.tab as string)
|
||||||
|
? (router.query.tab as TabValue)
|
||||||
|
: "deployments";
|
||||||
|
|
||||||
|
const setTab = (value: string) => {
|
||||||
|
if (!isValidTab(value)) return;
|
||||||
|
router.replace(
|
||||||
|
{ pathname: "/dashboard/deployments", query: { tab: value } },
|
||||||
|
undefined,
|
||||||
|
{ shallow: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
|
||||||
|
<div className="rounded-xl bg-background shadow-md h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<Rocket className="size-5" />
|
||||||
|
Deployments
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
All application and compose deployments in one place.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tabs value={tab} onValueChange={setTab} className="w-full">
|
||||||
|
<TabsList className="mt-2">
|
||||||
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
|
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="deployments" className="mt-0 pt-4">
|
||||||
|
<ShowDeploymentsTable />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="queue" className="mt-0 pt-4">
|
||||||
|
<ShowQueueTable />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardHeader>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeploymentsPage;
|
||||||
|
|
||||||
|
DeploymentsPage.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout>{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||||
|
const { user } = await validateRequest(ctx.req);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
props: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ import type {
|
|||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
import Link from "next/link";
|
||||||
import { type ReactElement, useEffect, useMemo, useState } from "react";
|
import { type ReactElement, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
@@ -358,7 +358,6 @@ const EnvironmentPage = (
|
|||||||
environmentId,
|
environmentId,
|
||||||
});
|
});
|
||||||
const { data: allProjects } = api.project.all.useQuery();
|
const { data: allProjects } = api.project.all.useQuery();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
|
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
|
||||||
const [selectedTargetProject, setSelectedTargetProject] =
|
const [selectedTargetProject, setSelectedTargetProject] =
|
||||||
@@ -412,6 +411,7 @@ const EnvironmentPage = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleServiceSelect = (serviceId: string, event: React.MouseEvent) => {
|
const handleServiceSelect = (serviceId: string, event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setSelectedServices((prev) =>
|
setSelectedServices((prev) =>
|
||||||
prev.includes(serviceId)
|
prev.includes(serviceId)
|
||||||
@@ -1452,101 +1452,99 @@ const EnvironmentPage = (
|
|||||||
<div className="flex w-full flex-col gap-4">
|
<div className="flex w-full flex-col gap-4">
|
||||||
<div className="gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
<div className="gap-5 pb-10 grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
{filteredServices?.map((service) => (
|
{filteredServices?.map((service) => (
|
||||||
<Card
|
<Link
|
||||||
key={service.id}
|
key={service.id}
|
||||||
onClick={() => {
|
href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`}
|
||||||
router.push(
|
className="block"
|
||||||
`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border"
|
|
||||||
>
|
>
|
||||||
{service.serverId && (
|
<Card className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border">
|
||||||
<div className="absolute -left-1 -top-2">
|
{service.serverId && (
|
||||||
<ServerIcon className="size-4 text-muted-foreground" />
|
<div className="absolute -left-1 -top-2">
|
||||||
</div>
|
<ServerIcon className="size-4 text-muted-foreground" />
|
||||||
)}
|
|
||||||
<div className="absolute -right-1 -top-2">
|
|
||||||
<StatusTooltip status={service.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
|
||||||
selectedServices.includes(service.id)
|
|
||||||
? "opacity-100 translate-y-0"
|
|
||||||
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
|
||||||
)}
|
|
||||||
onClick={(e) =>
|
|
||||||
handleServiceSelect(service.id, e)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="h-full w-full flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedServices.includes(
|
|
||||||
service.id,
|
|
||||||
)}
|
|
||||||
className="data-[state=checked]:bg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center justify-between">
|
|
||||||
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
|
||||||
{service.name}
|
|
||||||
</span>
|
|
||||||
{service.description && (
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
|
||||||
{service.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-sm font-medium text-muted-foreground self-start">
|
|
||||||
{service.type === "postgres" && (
|
|
||||||
<PostgresqlIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "redis" && (
|
|
||||||
<RedisIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "mariadb" && (
|
|
||||||
<MariadbIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "mongo" && (
|
|
||||||
<MongodbIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "mysql" && (
|
|
||||||
<MysqlIcon className="h-7 w-7" />
|
|
||||||
)}
|
|
||||||
{service.type === "application" && (
|
|
||||||
<GlobeIcon className="h-6 w-6" />
|
|
||||||
)}
|
|
||||||
{service.type === "compose" && (
|
|
||||||
<CircuitBoard className="h-6 w-6" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
)}
|
||||||
</CardHeader>
|
<div className="absolute -right-1 -top-2">
|
||||||
<CardFooter className="mt-auto">
|
<StatusTooltip status={service.status} />
|
||||||
<div className="space-y-1 text-sm w-full">
|
</div>
|
||||||
{service.serverName && (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
<div
|
||||||
<ServerIcon className="size-3" />
|
className={cn(
|
||||||
<span className="truncate">
|
"absolute -left-3 -bottom-3 size-9 translate-y-1 rounded-full p-0 transition-all duration-200 z-10 bg-background border",
|
||||||
{service.serverName}
|
selectedServices.includes(service.id)
|
||||||
|
? "opacity-100 translate-y-0"
|
||||||
|
: "opacity-0 group-hover:translate-y-0 group-hover:opacity-100",
|
||||||
|
)}
|
||||||
|
onClick={(e) =>
|
||||||
|
handleServiceSelect(service.id, e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="h-full w-full flex items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedServices.includes(
|
||||||
|
service.id,
|
||||||
|
)}
|
||||||
|
className="data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-row items-center gap-2 justify-between w-full">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-base flex items-center gap-2 font-medium leading-none flex-wrap">
|
||||||
|
{service.name}
|
||||||
|
</span>
|
||||||
|
{service.description && (
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{service.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-sm font-medium text-muted-foreground self-start">
|
||||||
|
{service.type === "postgres" && (
|
||||||
|
<PostgresqlIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "redis" && (
|
||||||
|
<RedisIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "mariadb" && (
|
||||||
|
<MariadbIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "mongo" && (
|
||||||
|
<MongodbIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "mysql" && (
|
||||||
|
<MysqlIcon className="h-7 w-7" />
|
||||||
|
)}
|
||||||
|
{service.type === "application" && (
|
||||||
|
<GlobeIcon className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
{service.type === "compose" && (
|
||||||
|
<CircuitBoard className="h-6 w-6" />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardTitle>
|
||||||
<DateTooltip date={service.createdAt}>
|
</CardHeader>
|
||||||
Created
|
<CardFooter className="mt-auto">
|
||||||
</DateTooltip>
|
<div className="space-y-1 text-sm w-full">
|
||||||
</div>
|
{service.serverName && (
|
||||||
</CardFooter>
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||||
</Card>
|
<ServerIcon className="size-3" />
|
||||||
|
<span className="truncate">
|
||||||
|
{service.serverName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DateTooltip date={service.createdAt}>
|
||||||
|
Created
|
||||||
|
</DateTooltip>
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import {
|
|||||||
findAllDeploymentsByApplicationId,
|
findAllDeploymentsByApplicationId,
|
||||||
findAllDeploymentsByComposeId,
|
findAllDeploymentsByComposeId,
|
||||||
findAllDeploymentsByServerId,
|
findAllDeploymentsByServerId,
|
||||||
|
findAllDeploymentsCentralized,
|
||||||
findApplicationById,
|
findApplicationById,
|
||||||
findComposeById,
|
findComposeById,
|
||||||
findDeploymentById,
|
findDeploymentById,
|
||||||
|
findMemberById,
|
||||||
findServerById,
|
findServerById,
|
||||||
|
IS_CLOUD,
|
||||||
removeDeployment,
|
removeDeployment,
|
||||||
|
resolveServicePath,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
@@ -21,7 +25,10 @@ import {
|
|||||||
apiFindAllByServer,
|
apiFindAllByServer,
|
||||||
apiFindAllByType,
|
apiFindAllByType,
|
||||||
deployments,
|
deployments,
|
||||||
|
server,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
import { myQueue } from "@/server/queues/queueSetup";
|
||||||
|
import { fetchDeployApiJobs, type QueueJobRow } from "@/server/utils/deploy";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
export const deploymentRouter = createTRPCRouter({
|
export const deploymentRouter = createTRPCRouter({
|
||||||
@@ -68,6 +75,63 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return await findAllDeploymentsByServerId(input.serverId);
|
return await findAllDeploymentsByServerId(input.serverId);
|
||||||
}),
|
}),
|
||||||
|
allCentralized: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const orgId = ctx.session.activeOrganizationId;
|
||||||
|
const accessedServices =
|
||||||
|
ctx.user.role === "member"
|
||||||
|
? (await findMemberById(ctx.user.id, orgId)).accessedServices
|
||||||
|
: null;
|
||||||
|
if (accessedServices !== null && accessedServices.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return findAllDeploymentsCentralized(orgId, accessedServices);
|
||||||
|
}),
|
||||||
|
|
||||||
|
queueList: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const orgId = ctx.session.activeOrganizationId;
|
||||||
|
let rows: QueueJobRow[];
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
const servers = await db.query.server.findMany({
|
||||||
|
where: eq(server.organizationId, orgId),
|
||||||
|
columns: { serverId: true },
|
||||||
|
});
|
||||||
|
const serverRowsArrays = await Promise.all(
|
||||||
|
servers.map(({ serverId }) => fetchDeployApiJobs(serverId)),
|
||||||
|
);
|
||||||
|
rows = serverRowsArrays.flat();
|
||||||
|
rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
||||||
|
} else {
|
||||||
|
const jobs = await myQueue.getJobs();
|
||||||
|
const jobRows = await Promise.all(
|
||||||
|
jobs.map(async (job) => {
|
||||||
|
const state = await job.getState();
|
||||||
|
return {
|
||||||
|
id: String(job.id),
|
||||||
|
name: job.name ?? undefined,
|
||||||
|
data: job.data as Record<string, unknown>,
|
||||||
|
timestamp: job.timestamp,
|
||||||
|
processedOn: job.processedOn,
|
||||||
|
finishedOn: job.finishedOn,
|
||||||
|
failedReason: job.failedReason ?? undefined,
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
jobRows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
||||||
|
rows = jobRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
rows.map(async (row) => ({
|
||||||
|
...row,
|
||||||
|
servicePath: await resolveServicePath(
|
||||||
|
orgId,
|
||||||
|
(row.data ?? {}) as Record<string, unknown>,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
allByType: protectedProcedure
|
allByType: protectedProcedure
|
||||||
.input(apiFindAllByType)
|
.input(apiFindAllByType)
|
||||||
@@ -79,10 +143,8 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
rollback: true,
|
rollback: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return deploymentsList;
|
return deploymentsList;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
killProcess: protectedProcedure
|
killProcess: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -69,8 +69,7 @@ export const mountRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(apiCreateMount)
|
.input(apiCreateMount)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await createMount(input);
|
return await createMount(input);
|
||||||
return true;
|
|
||||||
}),
|
}),
|
||||||
remove: protectedProcedure
|
remove: protectedProcedure
|
||||||
.input(apiRemoveMount)
|
.input(apiRemoveMount)
|
||||||
|
|||||||
@@ -355,4 +355,13 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
active: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
if (!ctx.session.activeOrganizationId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.query.organization.findFirst({
|
||||||
|
where: eq(organization.id, ctx.session.activeOrganizationId),
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,3 +50,34 @@ export const cancelDeployment = async (cancelData: CancelDeploymentData) => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QueueJobRow = {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
timestamp?: number;
|
||||||
|
processedOn?: number;
|
||||||
|
finishedOn?: number;
|
||||||
|
failedReason?: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDeployApiJobs = async (
|
||||||
|
serverId: string,
|
||||||
|
): Promise<QueueJobRow[]> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.SERVER_URL}/jobs?serverId=${encodeURIComponent(serverId)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": process.env.API_KEY || "NO-DEFINED",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
return (await res.json()) as QueueJobRow[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -99,17 +99,15 @@ const createSchema = createInsertSchema(mounts, {
|
|||||||
mountPath: z.string().min(1),
|
mountPath: z.string().min(1),
|
||||||
mountId: z.string().optional(),
|
mountId: z.string().optional(),
|
||||||
filePath: z.string().optional(),
|
filePath: z.string().optional(),
|
||||||
serviceType: z
|
serviceType: z.enum([
|
||||||
.enum([
|
"application",
|
||||||
"application",
|
"postgres",
|
||||||
"postgres",
|
"mysql",
|
||||||
"mysql",
|
"mariadb",
|
||||||
"mariadb",
|
"mongo",
|
||||||
"mongo",
|
"redis",
|
||||||
"redis",
|
"compose",
|
||||||
"compose",
|
]),
|
||||||
])
|
|
||||||
.default("application"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ServiceType = NonNullable<
|
export type ServiceType = NonNullable<
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
type apiCreateDeploymentSchedule,
|
type apiCreateDeploymentSchedule,
|
||||||
type apiCreateDeploymentServer,
|
type apiCreateDeploymentServer,
|
||||||
type apiCreateDeploymentVolumeBackup,
|
type apiCreateDeploymentVolumeBackup,
|
||||||
|
applications,
|
||||||
|
compose,
|
||||||
deployments,
|
deployments,
|
||||||
|
environments,
|
||||||
|
projects,
|
||||||
} from "@dokploy/server/db/schema";
|
} from "@dokploy/server/db/schema";
|
||||||
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +23,7 @@ import {
|
|||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq, and, inArray, or, sql } from "drizzle-orm";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import {
|
import {
|
||||||
type Application,
|
type Application,
|
||||||
@@ -38,6 +42,41 @@ import { findScheduleById } from "./schedule";
|
|||||||
import { findServerById, type Server } from "./server";
|
import { findServerById, type Server } from "./server";
|
||||||
import { findVolumeBackupById } from "./volume-backups";
|
import { findVolumeBackupById } from "./volume-backups";
|
||||||
|
|
||||||
|
export type ServicePath = { href: string | null; label: string };
|
||||||
|
|
||||||
|
export async function resolveServicePath(
|
||||||
|
orgId: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<ServicePath> {
|
||||||
|
try {
|
||||||
|
const applicationId = data?.applicationId as string | undefined;
|
||||||
|
const composeId = data?.composeId as string | undefined;
|
||||||
|
if (applicationId) {
|
||||||
|
const app = await findApplicationById(applicationId);
|
||||||
|
if (app.environment.project.organizationId !== orgId) {
|
||||||
|
return { href: null, label: "Application" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
||||||
|
label: "Application",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (composeId) {
|
||||||
|
const comp = await findComposeById(composeId);
|
||||||
|
if (comp.environment.project.organizationId !== orgId) {
|
||||||
|
return { href: null, label: "Compose" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
||||||
|
label: "Compose",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not found or unauthorized
|
||||||
|
}
|
||||||
|
return { href: null, label: "—" };
|
||||||
|
}
|
||||||
|
|
||||||
export type Deployment = typeof deployments.$inferSelect;
|
export type Deployment = typeof deployments.$inferSelect;
|
||||||
|
|
||||||
export const findDeploymentById = async (deploymentId: string) => {
|
export const findDeploymentById = async (deploymentId: string) => {
|
||||||
@@ -738,6 +777,135 @@ export const findAllDeploymentsByComposeId = async (composeId: string) => {
|
|||||||
return deploymentsList;
|
return deploymentsList;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const centralizedDeploymentsWith = {
|
||||||
|
application: {
|
||||||
|
columns: { applicationId: true, name: true, appName: true },
|
||||||
|
with: {
|
||||||
|
environment: {
|
||||||
|
columns: { environmentId: true, name: true },
|
||||||
|
with: {
|
||||||
|
project: {
|
||||||
|
columns: { projectId: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
columns: { serverId: true, name: true, serverType: true },
|
||||||
|
},
|
||||||
|
buildServer: {
|
||||||
|
columns: { serverId: true, name: true, serverType: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
compose: {
|
||||||
|
columns: { composeId: true, name: true, appName: true },
|
||||||
|
with: {
|
||||||
|
environment: {
|
||||||
|
columns: { environmentId: true, name: true },
|
||||||
|
with: {
|
||||||
|
project: {
|
||||||
|
columns: { projectId: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
columns: { serverId: true, name: true, serverType: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
columns: { serverId: true, name: true, serverType: true },
|
||||||
|
},
|
||||||
|
buildServer: {
|
||||||
|
columns: { serverId: true, name: true, serverType: true },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function getApplicationIdsInOrg(
|
||||||
|
orgId: string,
|
||||||
|
accessedServices: string[] | null,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ applicationId: applications.applicationId })
|
||||||
|
.from(applications)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(applications.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(
|
||||||
|
accessedServices !== null
|
||||||
|
? and(
|
||||||
|
eq(projects.organizationId, orgId),
|
||||||
|
inArray(applications.applicationId, accessedServices),
|
||||||
|
)
|
||||||
|
: eq(projects.organizationId, orgId),
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.applicationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getComposeIdsInOrg(
|
||||||
|
orgId: string,
|
||||||
|
accessedServices: string[] | null,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ composeId: compose.composeId })
|
||||||
|
.from(compose)
|
||||||
|
.innerJoin(
|
||||||
|
environments,
|
||||||
|
eq(compose.environmentId, environments.environmentId),
|
||||||
|
)
|
||||||
|
.innerJoin(projects, eq(environments.projectId, projects.projectId))
|
||||||
|
.where(
|
||||||
|
accessedServices !== null
|
||||||
|
? and(
|
||||||
|
eq(projects.organizationId, orgId),
|
||||||
|
inArray(compose.composeId, accessedServices),
|
||||||
|
)
|
||||||
|
: eq(projects.organizationId, orgId),
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.composeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All deployments for applications and compose in the org.
|
||||||
|
* Pass accessedServices for members (only those services), null for owner/admin.
|
||||||
|
*/
|
||||||
|
export const findAllDeploymentsCentralized = async (
|
||||||
|
orgId: string,
|
||||||
|
accessedServices: string[] | null,
|
||||||
|
) => {
|
||||||
|
if (accessedServices !== null && accessedServices.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [appIds, compIds] = await Promise.all([
|
||||||
|
getApplicationIdsInOrg(orgId, accessedServices),
|
||||||
|
getComposeIdsInOrg(orgId, accessedServices),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (appIds.length === 0 && compIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
...(appIds.length > 0 ? [inArray(deployments.applicationId, appIds)] : []),
|
||||||
|
...(compIds.length > 0 ? [inArray(deployments.composeId, compIds)] : []),
|
||||||
|
];
|
||||||
|
const whereClause =
|
||||||
|
conditions.length === 0
|
||||||
|
? sql`1 = 0`
|
||||||
|
: conditions.length === 1
|
||||||
|
? conditions[0]
|
||||||
|
: or(...conditions);
|
||||||
|
|
||||||
|
return db.query.deployments.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
orderBy: desc(deployments.createdAt),
|
||||||
|
with: centralizedDeploymentsWith,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const updateDeployment = async (
|
export const updateDeployment = async (
|
||||||
deploymentId: string,
|
deploymentId: string,
|
||||||
deploymentData: Partial<Deployment>,
|
deploymentData: Partial<Deployment>,
|
||||||
|
|||||||
Reference in New Issue
Block a user