mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge branch 'canary' into feat/add-whitelabelling
This commit is contained in:
@@ -1,2 +1,11 @@
|
||||
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,
|
||||
deployJobSchema,
|
||||
} from "./schema.js";
|
||||
import { fetchDeploymentJobs } from "./service.js";
|
||||
import { deploy } from "./utils.js";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
logger.error("Failed to send deployment event", error);
|
||||
return c.json(
|
||||
{
|
||||
@@ -176,6 +176,29 @@ app.get("/health", async (c) => {
|
||||
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
|
||||
app.on(
|
||||
["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,
|
||||
updatePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import type { DeployJob } from "./schema";
|
||||
import type { DeployJob } from "./schema.js";
|
||||
|
||||
export const deploy = async (job: DeployJob) => {
|
||||
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,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const organizationSchema = z.object({
|
||||
@@ -55,8 +54,6 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
const { mutateAsync, isLoading } = organizationId
|
||||
? api.organization.update.useMutation()
|
||||
: api.organization.create.useMutation();
|
||||
const { refetch: refetchActiveOrganization } =
|
||||
authClient.useActiveOrganization();
|
||||
|
||||
const form = useForm<OrganizationFormValues>({
|
||||
resolver: zodResolver(organizationSchema),
|
||||
@@ -89,7 +86,7 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
utils.organization.all.invalidate();
|
||||
if (organizationId) {
|
||||
utils.organization.one.invalidate({ organizationId });
|
||||
refetchActiveOrganization();
|
||||
utils.organization.active.invalidate();
|
||||
}
|
||||
setOpen(false);
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { StatusTooltip } from "../shared/status-tooltip";
|
||||
|
||||
@@ -56,7 +55,7 @@ export const SearchCommand = () => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const { data } = api.project.all.useQuery(undefined, {
|
||||
enabled: !!session,
|
||||
});
|
||||
@@ -174,6 +173,14 @@ export const SearchCommand = () => {
|
||||
>
|
||||
Projects
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/deployments");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Deployments
|
||||
</CommandItem>
|
||||
{!isCloud && (
|
||||
<>
|
||||
<CommandItem
|
||||
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const AddGithubProvider = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const { data } = api.user.get.useQuery();
|
||||
const [manifest, setManifest] = useState("");
|
||||
const [isOrganization, setIsOrganization] = useState(false);
|
||||
@@ -52,7 +52,7 @@ export const AddGithubProvider = () => {
|
||||
);
|
||||
|
||||
setManifest(manifest);
|
||||
}, [data?.id]);
|
||||
}, [activeOrganization?.id, session?.user?.id]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -98,8 +98,8 @@ export const AddGithubProvider = () => {
|
||||
<form
|
||||
action={
|
||||
isOrganization
|
||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||
}
|
||||
method="post"
|
||||
>
|
||||
|
||||
@@ -55,7 +55,7 @@ export const AddInvitation = () => {
|
||||
api.notification.getEmailProviders.useQuery();
|
||||
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const form = useForm<AddInvitation>({
|
||||
defaultValues: {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ShowUsers = () => {
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Package,
|
||||
Palette,
|
||||
PieChart,
|
||||
Rocket,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
@@ -146,6 +147,12 @@ const MENU: Menu = {
|
||||
url: "/dashboard/projects",
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Deployments",
|
||||
url: "/dashboard/deployments",
|
||||
icon: Rocket,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Monitoring",
|
||||
@@ -552,7 +559,7 @@ function SidebarLogo() {
|
||||
enabled: !isCloud,
|
||||
});
|
||||
const { data: user } = api.user.get.useQuery();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const {
|
||||
data: organizations,
|
||||
refetch,
|
||||
@@ -563,8 +570,7 @@ function SidebarLogo() {
|
||||
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
|
||||
api.organization.setDefault.useMutation();
|
||||
const { isMobile } = useSidebar();
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const _utils = api.useUtils();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const { data: invitations, refetch: refetchInvitations } =
|
||||
api.user.getInvitations.useQuery();
|
||||
|
||||
@@ -10,7 +10,8 @@ import { yaml } from "@codemirror/lang-yaml";
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { search, searchKeymap } from "@codemirror/search";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
||||
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
@@ -156,6 +157,8 @@ export const CodeEditor = ({
|
||||
}}
|
||||
theme={resolvedTheme === "dark" ? githubDark : githubLight}
|
||||
extensions={[
|
||||
search(),
|
||||
keymap.of(searchKeymap),
|
||||
language === "yaml"
|
||||
? yaml()
|
||||
: language === "json"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.27.1",
|
||||
"version": "v0.28.5",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -54,7 +54,8 @@
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/legacy-modes": "6.4.0",
|
||||
"@codemirror/view": "6.29.0",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@dokploy/trpc-openapi": "0.0.4",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
|
||||
@@ -10,22 +10,29 @@ type Query = {
|
||||
state: string;
|
||||
installation_id: string;
|
||||
setup_action: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const { code, state, installation_id, userId }: Query = req.query as Query;
|
||||
const { code, state, installation_id }: Query = req.query as Query;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "Missing code parameter" });
|
||||
}
|
||||
const [action, value] = state?.split(":");
|
||||
// Value could be the organizationId or the githubProviderId
|
||||
const [action, ...rest] = state?.split(":");
|
||||
// For gh_init: rest[0] = organizationId, rest[1] = userId
|
||||
// For gh_setup: rest[0] = githubProviderId
|
||||
|
||||
if (action === "gh_init") {
|
||||
const organizationId = rest[0];
|
||||
const userId = rest[1] || (req.query.userId as string);
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "Missing userId parameter" });
|
||||
}
|
||||
|
||||
const octokit = new Octokit({});
|
||||
const { data } = await octokit.request(
|
||||
"POST /app-manifests/{code}/conversions",
|
||||
@@ -44,7 +51,7 @@ export default async function handler(
|
||||
githubWebhookSecret: data.webhook_secret,
|
||||
githubPrivateKey: data.pem,
|
||||
},
|
||||
value as string,
|
||||
organizationId as string,
|
||||
userId,
|
||||
);
|
||||
} else if (action === "gh_setup") {
|
||||
@@ -53,7 +60,7 @@ export default async function handler(
|
||||
.set({
|
||||
githubInstallationId: installation_id,
|
||||
})
|
||||
.where(eq(github.githubId, value as string))
|
||||
.where(eq(github.githubId, rest[0] as string))
|
||||
.returning();
|
||||
}
|
||||
|
||||
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
@@ -785,7 +785,7 @@ const EnvironmentPage = (
|
||||
}
|
||||
if (success > 0) {
|
||||
toast.success(
|
||||
`${success} service${success !== 1 ? "s" : ""} deployed successfully`,
|
||||
`${success} service${success !== 1 ? "s" : ""} queued for deployment`,
|
||||
);
|
||||
}
|
||||
if (failed > 0) {
|
||||
|
||||
@@ -4,10 +4,15 @@ import {
|
||||
findAllDeploymentsByApplicationId,
|
||||
findAllDeploymentsByComposeId,
|
||||
findAllDeploymentsByServerId,
|
||||
findAllDeploymentsCentralized,
|
||||
findApplicationById,
|
||||
findComposeById,
|
||||
findDeploymentById,
|
||||
findMemberById,
|
||||
findServerById,
|
||||
IS_CLOUD,
|
||||
removeDeployment,
|
||||
resolveServicePath,
|
||||
updateDeploymentStatus,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -20,7 +25,10 @@ import {
|
||||
apiFindAllByServer,
|
||||
apiFindAllByType,
|
||||
deployments,
|
||||
server,
|
||||
} from "@/server/db/schema";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
import { fetchDeployApiJobs, type QueueJobRow } from "@/server/utils/deploy";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const deploymentRouter = createTRPCRouter({
|
||||
@@ -67,6 +75,63 @@ export const deploymentRouter = createTRPCRouter({
|
||||
}
|
||||
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
|
||||
.input(apiFindAllByType)
|
||||
@@ -78,10 +143,8 @@ export const deploymentRouter = createTRPCRouter({
|
||||
rollback: true,
|
||||
},
|
||||
});
|
||||
|
||||
return deploymentsList;
|
||||
}),
|
||||
|
||||
killProcess: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -21,8 +21,7 @@ export const mountRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateMount)
|
||||
.mutation(async ({ input }) => {
|
||||
await createMount(input);
|
||||
return true;
|
||||
return await createMount(input);
|
||||
}),
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveMount)
|
||||
|
||||
@@ -355,4 +355,13 @@ export const organizationRouter = createTRPCRouter({
|
||||
|
||||
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),
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -149,12 +149,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
// Check if port 8080 is already in use before enabling dashboard
|
||||
const portCheck = await checkPortInUse(8080, input.serverId);
|
||||
if (portCheck.isInUse) {
|
||||
const conflictingContainer = portCheck.conflictingContainer
|
||||
? ` by container "${portCheck.conflictingContainer}"`
|
||||
const conflictInfo = portCheck.conflictingContainer
|
||||
? ` by ${portCheck.conflictingContainer}`
|
||||
: "";
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
|
||||
message: `Port 8080 is already in use${conflictInfo}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
|
||||
});
|
||||
}
|
||||
newPorts.push({
|
||||
|
||||
@@ -101,6 +101,16 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
return memberResult;
|
||||
}),
|
||||
session: protectedProcedure.query(async ({ ctx }) => {
|
||||
return {
|
||||
user: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
session: {
|
||||
activeOrganizationId: ctx.session.activeOrganizationId,
|
||||
},
|
||||
};
|
||||
}),
|
||||
get: protectedProcedure.query(async ({ ctx }) => {
|
||||
const memberResult = await db.query.member.findFirst({
|
||||
where: and(
|
||||
|
||||
@@ -50,3 +50,34 @@ export const cancelDeployment = async (cancelData: CancelDeploymentData) => {
|
||||
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 [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,13 +58,13 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
|
||||
WITH recent_metrics AS (
|
||||
SELECT metrics_json
|
||||
FROM container_metrics
|
||||
WHERE container_name = ?
|
||||
WHERE container_name = ? OR container_name LIKE ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
)
|
||||
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
||||
`
|
||||
rows, err := db.Query(query, containerName, limit)
|
||||
rows, err := db.Query(query, containerName, containerName+".%", limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -98,12 +98,12 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
|
||||
WITH recent_metrics AS (
|
||||
SELECT metrics_json
|
||||
FROM container_metrics
|
||||
WHERE container_name = ?
|
||||
WHERE container_name = ? OR container_name LIKE ?
|
||||
ORDER BY timestamp DESC
|
||||
)
|
||||
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
||||
`
|
||||
rows, err := db.Query(query, containerName)
|
||||
rows, err := db.Query(query, containerName, containerName+".%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,8 +2,24 @@ import path from "node:path";
|
||||
import Docker from "dockerode";
|
||||
|
||||
export const IS_CLOUD = process.env.IS_CLOUD === "true";
|
||||
export const DOCKER_API_VERSION = process.env.DOCKER_API_VERSION;
|
||||
export const DOCKER_HOST = process.env.DOCKER_HOST;
|
||||
export const DOCKER_PORT = process.env.DOCKER_PORT
|
||||
? Number(process.env.DOCKER_PORT)
|
||||
: undefined;
|
||||
|
||||
export const CLEANUP_CRON_JOB = "50 23 * * *";
|
||||
export const docker = new Docker();
|
||||
export const docker = new Docker({
|
||||
...(DOCKER_API_VERSION && {
|
||||
version: DOCKER_API_VERSION,
|
||||
}),
|
||||
...(DOCKER_HOST && {
|
||||
host: DOCKER_HOST,
|
||||
}),
|
||||
...(DOCKER_PORT && {
|
||||
port: DOCKER_PORT,
|
||||
}),
|
||||
});
|
||||
|
||||
// When not set, use the legacy default so 2FA remains working for users who
|
||||
// enabled it before BETTER_AUTH_SECRET was introduced .
|
||||
|
||||
@@ -362,12 +362,13 @@ const createSchema = createInsertSchema(applications, {
|
||||
previewPath: z.string().optional(),
|
||||
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
watchPaths: z.array(z.string()).optional().optional(),
|
||||
previewLabels: z.array(z.string()).optional(),
|
||||
cleanCache: z.boolean().optional(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
enableSubmodules: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateApplication = createSchema.pick({
|
||||
@@ -432,13 +433,13 @@ export const apiSaveGithubProvider = createSchema
|
||||
owner: true,
|
||||
buildPath: true,
|
||||
githubId: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required()
|
||||
.extend({
|
||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||
});
|
||||
})
|
||||
.required()
|
||||
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||
|
||||
export const apiSaveGitlabProvider = createSchema
|
||||
.pick({
|
||||
@@ -450,10 +451,9 @@ export const apiSaveGitlabProvider = createSchema
|
||||
gitlabId: true,
|
||||
gitlabProjectId: true,
|
||||
gitlabPathNamespace: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||
|
||||
export const apiSaveBitbucketProvider = createSchema
|
||||
.pick({
|
||||
@@ -464,10 +464,9 @@ export const apiSaveBitbucketProvider = createSchema
|
||||
bitbucketRepositorySlug: true,
|
||||
bitbucketId: true,
|
||||
applicationId: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||
|
||||
export const apiSaveGiteaProvider = createSchema
|
||||
.pick({
|
||||
@@ -477,10 +476,9 @@ export const apiSaveGiteaProvider = createSchema
|
||||
giteaOwner: true,
|
||||
giteaRepository: true,
|
||||
giteaId: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
|
||||
|
||||
export const apiSaveDockerProvider = createSchema
|
||||
.pick({
|
||||
@@ -505,6 +503,7 @@ export const apiSaveGitProvider = createSchema
|
||||
.merge(
|
||||
createSchema.pick({
|
||||
customGitSSHKeyId: true,
|
||||
enableSubmodules: true,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -99,17 +99,15 @@ const createSchema = createInsertSchema(mounts, {
|
||||
mountPath: z.string().min(1),
|
||||
mountId: z.string().optional(),
|
||||
filePath: z.string().optional(),
|
||||
serviceType: z
|
||||
.enum([
|
||||
"application",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"compose",
|
||||
])
|
||||
.default("application"),
|
||||
serviceType: z.enum([
|
||||
"application",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"compose",
|
||||
]),
|
||||
});
|
||||
|
||||
export type ServiceType = NonNullable<
|
||||
|
||||
@@ -125,7 +125,37 @@ export const getTrustedOrigins = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
if (members.length === 0) {
|
||||
if (IS_CLOUD) {
|
||||
const now = Date.now();
|
||||
if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) {
|
||||
return trustedOriginsCache.data;
|
||||
}
|
||||
try {
|
||||
const trustedOrigins = await runQuery();
|
||||
trustedOriginsCache = {
|
||||
data: trustedOrigins,
|
||||
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
|
||||
};
|
||||
return trustedOrigins;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch trusted origins:", error);
|
||||
return trustedOriginsCache?.data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await runQuery();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch trusted origins:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getTrustedProviders = async () => {
|
||||
try {
|
||||
const providers = await db.query.ssoProvider.findMany();
|
||||
return providers.map((provider) => provider.providerId);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,18 @@ import {
|
||||
type apiCreateDeploymentSchedule,
|
||||
type apiCreateDeploymentServer,
|
||||
type apiCreateDeploymentVolumeBackup,
|
||||
applications,
|
||||
compose,
|
||||
deployments,
|
||||
environments,
|
||||
projects,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { format } from "date-fns";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
type Application,
|
||||
findApplicationById,
|
||||
@@ -34,6 +39,41 @@ import { findScheduleById } from "./schedule";
|
||||
import { findServerById, type Server } from "./server";
|
||||
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 const findDeploymentById = async (deploymentId: string) => {
|
||||
@@ -74,12 +114,12 @@ export const createDeployment = async (
|
||||
>,
|
||||
) => {
|
||||
const application = await findApplicationById(deployment.applicationId);
|
||||
await removeLastTenDeployments(
|
||||
deployment.applicationId,
|
||||
"application",
|
||||
application.serverId,
|
||||
);
|
||||
try {
|
||||
await removeLastTenDeployments(
|
||||
deployment.applicationId,
|
||||
"application",
|
||||
application.serverId,
|
||||
);
|
||||
const serverId = application.buildServerId || application.serverId;
|
||||
|
||||
const { LOGS_PATH } = paths(!!serverId);
|
||||
@@ -157,13 +197,12 @@ export const createDeploymentPreview = async (
|
||||
const previewDeployment = await findPreviewDeploymentById(
|
||||
deployment.previewDeploymentId,
|
||||
);
|
||||
await removeLastTenDeployments(
|
||||
deployment.previewDeploymentId,
|
||||
"previewDeployment",
|
||||
previewDeployment?.application?.serverId,
|
||||
);
|
||||
try {
|
||||
await removeLastTenDeployments(
|
||||
deployment.previewDeploymentId,
|
||||
"previewDeployment",
|
||||
previewDeployment?.application?.serverId,
|
||||
);
|
||||
|
||||
const appName = `${previewDeployment.appName}`;
|
||||
const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
@@ -238,12 +277,12 @@ export const createDeploymentCompose = async (
|
||||
>,
|
||||
) => {
|
||||
const compose = await findComposeById(deployment.composeId);
|
||||
await removeLastTenDeployments(
|
||||
deployment.composeId,
|
||||
"compose",
|
||||
compose.serverId,
|
||||
);
|
||||
try {
|
||||
await removeLastTenDeployments(
|
||||
deployment.composeId,
|
||||
"compose",
|
||||
compose.serverId,
|
||||
);
|
||||
const { LOGS_PATH } = paths(!!compose.serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${compose.appName}-${formattedDateTime}.log`;
|
||||
@@ -326,8 +365,8 @@ export const createDeploymentBackup = async (
|
||||
} else if (backup.backupType === "compose") {
|
||||
serverId = backup.compose?.serverId;
|
||||
}
|
||||
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
|
||||
try {
|
||||
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
|
||||
const { LOGS_PATH } = paths(!!serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${backup.appName}-${formattedDateTime}.log`;
|
||||
@@ -396,12 +435,12 @@ export const createDeploymentSchedule = async (
|
||||
) => {
|
||||
const schedule = await findScheduleById(deployment.scheduleId);
|
||||
|
||||
const serverId =
|
||||
schedule.application?.serverId ||
|
||||
schedule.compose?.serverId ||
|
||||
schedule.server?.serverId;
|
||||
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
|
||||
try {
|
||||
const serverId =
|
||||
schedule.application?.serverId ||
|
||||
schedule.compose?.serverId ||
|
||||
schedule.server?.serverId;
|
||||
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
|
||||
const { SCHEDULES_PATH } = paths(!!serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${schedule.appName}-${formattedDateTime}.log`;
|
||||
@@ -472,14 +511,14 @@ export const createDeploymentVolumeBackup = async (
|
||||
) => {
|
||||
const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId);
|
||||
|
||||
const serverId =
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
await removeLastTenDeployments(
|
||||
deployment.volumeBackupId,
|
||||
"volumeBackup",
|
||||
serverId,
|
||||
);
|
||||
try {
|
||||
const serverId =
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
await removeLastTenDeployments(
|
||||
deployment.volumeBackupId,
|
||||
"volumeBackup",
|
||||
serverId,
|
||||
);
|
||||
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`;
|
||||
@@ -554,11 +593,27 @@ export const removeDeployment = async (deploymentId: string) => {
|
||||
const deployment = await db
|
||||
.delete(deployments)
|
||||
.where(eq(deployments.deploymentId, deploymentId))
|
||||
.returning();
|
||||
return deployment[0];
|
||||
.returning()
|
||||
.then((result) => result[0]);
|
||||
|
||||
if (!deployment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const logPath = path.join(deployment.logPath);
|
||||
if (logPath && logPath !== ".") {
|
||||
const command = `rm -f ${logPath};`;
|
||||
if (deployment.serverId) {
|
||||
await execAsyncRemote(deployment.serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
}
|
||||
|
||||
return deployment;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Error creating the deployment";
|
||||
error instanceof Error ? error.message : "Error removing the deployment";
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message,
|
||||
@@ -626,34 +681,49 @@ const removeLastTenDeployments = async (
|
||||
if (serverId) {
|
||||
let command = "";
|
||||
for (const oldDeployment of deploymentsToDelete) {
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
}
|
||||
try {
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
}
|
||||
|
||||
if (logPath !== ".") {
|
||||
command += `
|
||||
rm -rf ${logPath};
|
||||
`;
|
||||
if (logPath && logPath !== ".") {
|
||||
command += `rm -rf ${logPath};`;
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
}
|
||||
|
||||
await execAsyncRemote(serverId, command);
|
||||
if (command) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
}
|
||||
} else {
|
||||
for (const oldDeployment of deploymentsToDelete) {
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
try {
|
||||
if (oldDeployment.rollbackId) {
|
||||
await removeRollbackById(oldDeployment.rollbackId);
|
||||
}
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (
|
||||
logPath &&
|
||||
logPath !== "." &&
|
||||
existsSync(logPath) &&
|
||||
!oldDeployment.errorMessage
|
||||
) {
|
||||
await fsPromises.unlink(logPath);
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
const logPath = path.join(oldDeployment.logPath);
|
||||
if (
|
||||
existsSync(logPath) &&
|
||||
!oldDeployment.errorMessage &&
|
||||
logPath !== "."
|
||||
) {
|
||||
await fsPromises.unlink(logPath);
|
||||
}
|
||||
await removeDeployment(oldDeployment.deploymentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -717,6 +787,135 @@ export const findAllDeploymentsByComposeId = async (composeId: string) => {
|
||||
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 (
|
||||
deploymentId: string,
|
||||
deploymentData: Partial<Deployment>,
|
||||
|
||||
@@ -15,7 +15,7 @@ function shEscape(s: string | undefined): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function safeDockerLoginCommand(
|
||||
export function safeDockerLoginCommand(
|
||||
registry: string | undefined,
|
||||
user: string | undefined,
|
||||
pass: string | undefined,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { findDeploymentById } from "./deployment";
|
||||
import type { Mount } from "./mount";
|
||||
import type { Port } from "./port";
|
||||
import type { Project } from "./project";
|
||||
import type { Registry } from "./registry";
|
||||
import { type Registry, safeDockerLoginCommand } from "./registry";
|
||||
|
||||
export const createRollback = async (
|
||||
input: z.infer<typeof createRollbackSchema>,
|
||||
@@ -111,7 +111,7 @@ const deleteRollbackImage = async (image: string, serverId?: string | null) => {
|
||||
const command = `docker image rm ${image} --force`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(command, serverId);
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
@@ -171,6 +171,23 @@ export const rollback = async (rollbackId: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
const dockerLoginForRegistry = async (
|
||||
registry: Registry,
|
||||
serverId?: string | null,
|
||||
) => {
|
||||
const loginCommand = safeDockerLoginCommand(
|
||||
registry.registryUrl,
|
||||
registry.username,
|
||||
registry.password,
|
||||
);
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, loginCommand);
|
||||
} else {
|
||||
await execAsync(loginCommand);
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackApplication = async (
|
||||
appName: string,
|
||||
image: string,
|
||||
@@ -188,6 +205,14 @@ const rollbackApplication = async (
|
||||
throw new Error("Full context is required for rollback");
|
||||
}
|
||||
|
||||
// Ensure Docker daemon is authenticated with the rollback registry
|
||||
// before updating the swarm service. The authconfig in CreateServiceOptions
|
||||
// alone is not sufficient — Docker Swarm also relies on the daemon's
|
||||
// cached credentials (~/.docker/config.json) to distribute auth to nodes.
|
||||
if (fullContext.rollbackRegistry) {
|
||||
await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
|
||||
// Use the same configuration as mechanizeDockerContainer
|
||||
|
||||
@@ -413,17 +413,38 @@ export const checkPortInUse = async (
|
||||
serverId?: string,
|
||||
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
|
||||
try {
|
||||
const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
// Check if port is in use by a Docker container
|
||||
const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
|
||||
const { stdout: dockerOut } = serverId
|
||||
? await execAsyncRemote(serverId, dockerCommand)
|
||||
: await execAsync(dockerCommand);
|
||||
|
||||
const container = stdout.trim();
|
||||
const container = dockerOut.trim();
|
||||
|
||||
return {
|
||||
isInUse: !!container,
|
||||
conflictingContainer: container || undefined,
|
||||
};
|
||||
if (container) {
|
||||
return {
|
||||
isInUse: true,
|
||||
conflictingContainer: `container "${container}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if port is in use by a host-level service (non-Docker)
|
||||
// Dokploy runs inside a container, so we spawn an ephemeral container
|
||||
// with --net=host to share the host's network stack and use nc -z to
|
||||
// check if something is listening on the port
|
||||
const hostCommand = `docker run --rm --net=host busybox sh -c 'nc -z 0.0.0.0 ${port} 2>/dev/null && echo in_use || echo free'`;
|
||||
const { stdout: hostOut } = serverId
|
||||
? await execAsyncRemote(serverId, hostCommand)
|
||||
: await execAsync(hostCommand);
|
||||
|
||||
if (hostOut.includes("in_use")) {
|
||||
return {
|
||||
isInUse: true,
|
||||
conflictingContainer: "a host-level service",
|
||||
};
|
||||
}
|
||||
|
||||
return { isInUse: false };
|
||||
} catch (error) {
|
||||
console.error("Error checking port availability:", error);
|
||||
return { isInUse: false };
|
||||
|
||||
@@ -30,6 +30,18 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
||||
baseURL: config.apiUrl,
|
||||
});
|
||||
case "azure":
|
||||
// Azure OpenAI-compatible endpoints already include /v1 in the path.
|
||||
// Using createAzure with such URLs causes a doubled /v1//v1/ suffix.
|
||||
if (config.apiUrl.includes("/v1")) {
|
||||
return createOpenAICompatible({
|
||||
name: "azure",
|
||||
baseURL: config.apiUrl,
|
||||
headers: {
|
||||
"api-key": config.apiKey,
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return createAzure({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.apiUrl,
|
||||
|
||||
@@ -14,13 +14,14 @@ export const runComposeBackup = async (
|
||||
compose: Compose,
|
||||
backup: BackupSchedule,
|
||||
) => {
|
||||
const { environmentId, name } = compose;
|
||||
const { environmentId, name, appName } = compose;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix, databaseType } = backup;
|
||||
const { prefix, databaseType, serviceName } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
|
||||
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "Compose Backup",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
|
||||
import { member } from "@dokploy/server/db/schema";
|
||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||
@@ -11,7 +10,7 @@ import { startLogCleanup } from "../access-log/handler";
|
||||
import { cleanupAll } from "../docker/utils";
|
||||
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getS3Credentials, scheduleBackup } from "./utils";
|
||||
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
|
||||
|
||||
export const initCronJobs = async () => {
|
||||
console.log("Setting up cron jobs....");
|
||||
@@ -95,6 +94,20 @@ export const initCronJobs = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getServiceAppName = (backup: BackupSchedule): string => {
|
||||
if (backup.compose?.appName) {
|
||||
return backup.serviceName
|
||||
? `${backup.compose.appName}_${backup.serviceName}`
|
||||
: backup.compose.appName;
|
||||
}
|
||||
const serviceAppName =
|
||||
backup.postgres?.appName ||
|
||||
backup.mysql?.appName ||
|
||||
backup.mariadb?.appName ||
|
||||
backup.mongo?.appName;
|
||||
return serviceAppName || backup.appName;
|
||||
};
|
||||
|
||||
export const keepLatestNBackups = async (
|
||||
backup: BackupSchedule,
|
||||
serverId?: string | null,
|
||||
@@ -105,18 +118,16 @@ export const keepLatestNBackups = async (
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(backup.destination);
|
||||
const backupFilesPath = path.join(
|
||||
`:s3:${backup.destination.bucket}`,
|
||||
backup.prefix,
|
||||
);
|
||||
const appName = getServiceAppName(backup);
|
||||
const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
|
||||
|
||||
// --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
|
||||
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`;
|
||||
// when we pipe the above command with this one, we only get the list of files we want to delete
|
||||
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
|
||||
// this command deletes the files
|
||||
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}/{}
|
||||
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}/{}`;
|
||||
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}{}
|
||||
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
|
||||
|
||||
const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`;
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ export const runMariadbBackup = async (
|
||||
mariadb: Mariadb,
|
||||
backup: BackupSchedule,
|
||||
) => {
|
||||
const { environmentId, name } = mariadb;
|
||||
const { environmentId, name, appName } = mariadb;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "MariaDB Backup",
|
||||
|
||||
@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
||||
|
||||
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
||||
const { environmentId, name } = mongo;
|
||||
const { environmentId, name, appName } = mongo;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "MongoDB Backup",
|
||||
|
||||
@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
|
||||
|
||||
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||
const { environmentId, name } = mysql;
|
||||
const { environmentId, name, appName } = mysql;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "MySQL Backup",
|
||||
|
||||
@@ -14,7 +14,7 @@ export const runPostgresBackup = async (
|
||||
postgres: Postgres,
|
||||
backup: BackupSchedule,
|
||||
) => {
|
||||
const { name, environmentId } = postgres;
|
||||
const { name, environmentId, appName } = postgres;
|
||||
const environment = await findEnvironmentById(environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
|
||||
@@ -26,7 +26,7 @@ export const runPostgresBackup = async (
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
@@ -31,7 +31,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
const { BASE_PATH } = paths();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
|
||||
const backupFileName = `webserver-backup-${timestamp}.zip`;
|
||||
const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
|
||||
const s3Path = `:s3:${destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
|
||||
|
||||
try {
|
||||
await execAsync(`mkdir -p ${tempDir}/filesystem`);
|
||||
@@ -67,7 +67,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
await execAsync(cleanupCommand);
|
||||
|
||||
await execAsync(
|
||||
`rsync -a --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
||||
`rsync -a --ignore-errors --no-specials --no-devices ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
||||
);
|
||||
|
||||
writeStream.write("Copied filesystem to temp directory\n");
|
||||
|
||||
@@ -53,7 +53,7 @@ Compose Type: ${composeType} ✅`;
|
||||
|
||||
cd "${projectPath}";
|
||||
|
||||
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}` : ""}
|
||||
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""}
|
||||
env -i PATH="$PATH" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
|
||||
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ export const randomizeComposeFile = async (
|
||||
) => {
|
||||
const compose = await findComposeById(composeId);
|
||||
const composeFile = compose.composeFile;
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = parse(composeFile, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
|
||||
const randomSuffix = suffix || generateRandomHash();
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ export const loadDockerCompose = async (
|
||||
|
||||
if (existsSync(path)) {
|
||||
const yamlStr = readFileSync(path, "utf8");
|
||||
const parsedConfig = parse(yamlStr) as ComposeSpecification;
|
||||
const parsedConfig = parse(yamlStr, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
return parsedConfig;
|
||||
}
|
||||
return null;
|
||||
@@ -86,7 +88,9 @@ export const loadDockerComposeRemote = async (
|
||||
return null;
|
||||
}
|
||||
if (!stdout) return null;
|
||||
const parsedConfig = parse(stdout) as ComposeSpecification;
|
||||
const parsedConfig = parse(stdout, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
return parsedConfig;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -209,7 +209,10 @@ export const testGiteaConnection = async (input: { giteaId: string }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = provider.giteaUrl.replace(/\/+$/, "");
|
||||
const baseUrl = (provider.giteaInternalUrl || provider.giteaUrl).replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
|
||||
// Use /user/repos to get authenticated user's repositories with pagination
|
||||
let allRepos = 0;
|
||||
@@ -266,7 +269,9 @@ export const getGiteaRepositories = async (giteaId?: string) => {
|
||||
await refreshGiteaToken(giteaId);
|
||||
const giteaProvider = await findGiteaById(giteaId);
|
||||
|
||||
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, "");
|
||||
const baseUrl = (
|
||||
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
// Use /user/repos to get authenticated user's repositories with pagination
|
||||
let allRepositories: any[] = [];
|
||||
@@ -331,7 +336,9 @@ export const getGiteaBranches = async (input: {
|
||||
|
||||
const giteaProvider = await findGiteaById(input.giteaId);
|
||||
|
||||
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, "");
|
||||
const baseUrl = (
|
||||
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
// Handle pagination for branches
|
||||
let allBranches: any[] = [];
|
||||
|
||||
@@ -211,10 +211,13 @@ export const getGitlabBranches = async (input: {
|
||||
const allBranches = [];
|
||||
let page = 1;
|
||||
const perPage = 100; // GitLab's max per page is 100
|
||||
const baseUrl = (
|
||||
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
while (true) {
|
||||
const branchesResponse = await fetch(
|
||||
`${gitlabProvider.gitlabUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
|
||||
`${baseUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${gitlabProvider.accessToken}`,
|
||||
@@ -289,10 +292,13 @@ export const validateGitlabProvider = async (gitlabProvider: Gitlab) => {
|
||||
const allProjects = [];
|
||||
let page = 1;
|
||||
const perPage = 100; // GitLab's max per page is 100
|
||||
const baseUrl = (
|
||||
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
|
||||
`${baseUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${gitlabProvider.accessToken}`,
|
||||
|
||||
@@ -69,6 +69,7 @@ export const restoreComposeBackup = async (
|
||||
},
|
||||
restoreType: composeType,
|
||||
rcloneCommand,
|
||||
backupFile: backupInput.backupFile,
|
||||
});
|
||||
|
||||
emit("Starting restore...");
|
||||
|
||||
@@ -30,7 +30,7 @@ export const getMongoRestoreCommand = (
|
||||
databaseUser: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive"`;
|
||||
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`;
|
||||
};
|
||||
|
||||
export const getComposeSearchCommand = (
|
||||
|
||||
@@ -152,16 +152,13 @@ export const createRouterConfig = async (
|
||||
}
|
||||
|
||||
if ((entryPoint === "websecure" && https) || !https) {
|
||||
// redirects
|
||||
for (const redirect of redirects) {
|
||||
let middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
|
||||
if (domain.domainType === "preview") {
|
||||
middlewareName = `redirect-${appName.replace(
|
||||
/^preview-(.+)-[^-]+$/,
|
||||
"$1",
|
||||
)}-${redirect.uniqueConfigKey}`;
|
||||
// redirects - skip for preview deployments as wildcard subdomains
|
||||
// should not inherit parent redirect rules (e.g., www-redirect)
|
||||
if (domain.domainType !== "preview") {
|
||||
for (const redirect of redirects) {
|
||||
const middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
|
||||
routerConfig.middlewares?.push(middlewareName);
|
||||
}
|
||||
routerConfig.middlewares?.push(middlewareName);
|
||||
}
|
||||
|
||||
// security
|
||||
|
||||
@@ -4,6 +4,24 @@ import { findComposeById } from "@dokploy/server/services/compose";
|
||||
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
|
||||
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
|
||||
|
||||
export const getVolumeServiceAppName = (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
): string => {
|
||||
if (volumeBackup.compose?.appName) {
|
||||
return volumeBackup.serviceName
|
||||
? `${volumeBackup.compose.appName}_${volumeBackup.serviceName}`
|
||||
: volumeBackup.compose.appName;
|
||||
}
|
||||
const serviceAppName =
|
||||
volumeBackup.application?.appName ||
|
||||
volumeBackup.postgres?.appName ||
|
||||
volumeBackup.mysql?.appName ||
|
||||
volumeBackup.mariadb?.appName ||
|
||||
volumeBackup.mongo?.appName ||
|
||||
volumeBackup.redis?.appName;
|
||||
return serviceAppName || volumeBackup.appName;
|
||||
};
|
||||
|
||||
export const backupVolume = async (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
) => {
|
||||
@@ -12,8 +30,9 @@ export const backupVolume = async (
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId);
|
||||
const destination = volumeBackup.destination;
|
||||
const s3AppName = getVolumeServiceAppName(volumeBackup);
|
||||
const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`;
|
||||
const rcloneFlags = getS3Credentials(volumeBackup.destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
|
||||
import { sendVolumeBackupNotifications } from "../notifications/volume-backup";
|
||||
import { backupVolume } from "./backup";
|
||||
import { backupVolume, getVolumeServiceAppName } from "./backup";
|
||||
|
||||
// Helper functions to extract project info from volume backup
|
||||
const getProjectName = (
|
||||
@@ -81,9 +81,9 @@ const cleanupOldVolumeBackups = async (
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const normalizedPrefix = normalizeS3Path(prefix);
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${normalizedPrefix}`;
|
||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" :s3:${destination.bucket}/${normalizedPrefix}`;
|
||||
const s3AppName = getVolumeServiceAppName(volumeBackup);
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${s3AppName}/${normalizeS3Path(prefix || "")}`;
|
||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" ${backupFilesPath}`;
|
||||
const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`;
|
||||
const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
|
||||
const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`;
|
||||
@@ -131,14 +131,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
? "mongodb"
|
||||
: volumeBackup.serviceType;
|
||||
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "success",
|
||||
organizationId,
|
||||
});
|
||||
try {
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "success",
|
||||
organizationId,
|
||||
});
|
||||
} catch (notificationError) {
|
||||
console.error(
|
||||
"Failed to send volume backup success notification",
|
||||
notificationError,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||
const volumeBackupPath = path.join(
|
||||
@@ -160,14 +167,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
? "mongodb"
|
||||
: volumeBackup.serviceType;
|
||||
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "error",
|
||||
organizationId,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
try {
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "error",
|
||||
organizationId,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} catch (notificationError) {
|
||||
console.error(
|
||||
"Failed to send volume backup error notification",
|
||||
notificationError,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
120
pnpm-lock.yaml
generated
120
pnpm-lock.yaml
generated
@@ -130,9 +130,12 @@ importers:
|
||||
'@codemirror/legacy-modes':
|
||||
specifier: 6.4.0
|
||||
version: 6.4.0
|
||||
'@codemirror/search':
|
||||
specifier: ^6.6.0
|
||||
version: 6.6.0
|
||||
'@codemirror/view':
|
||||
specifier: 6.29.0
|
||||
version: 6.29.0
|
||||
specifier: ^6.39.15
|
||||
version: 6.39.15
|
||||
'@dokploy/server':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/server
|
||||
@@ -240,10 +243,10 @@ importers:
|
||||
version: 10.45.2
|
||||
'@uiw/codemirror-theme-github':
|
||||
specifier: ^4.23.12
|
||||
version: 4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)
|
||||
version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
|
||||
'@uiw/react-codemirror':
|
||||
specifier: ^4.23.12
|
||||
version: 4.23.12(@babel/runtime@7.27.3)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.29.0)(codemirror@6.0.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.15)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
'@xterm/addon-attach':
|
||||
specifier: 0.10.0
|
||||
version: 0.10.0(@xterm/xterm@5.5.0)
|
||||
@@ -1200,11 +1203,8 @@ packages:
|
||||
'@codemirror/theme-one-dark@6.1.2':
|
||||
resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==}
|
||||
|
||||
'@codemirror/view@6.29.0':
|
||||
resolution: {integrity: sha512-ED4ims4fkf7eOA+HYLVP8VVg3NMllt1FPm9PEJBfYFnidKlRITBaua38u68L1F60eNtw2YNcDN5jsIzhKZwWQA==}
|
||||
|
||||
'@codemirror/view@6.36.8':
|
||||
resolution: {integrity: sha512-yoRo4f+FdnD01fFt4XpfpMCcCAo9QvZOtbrXExn4SqzH32YC6LgzqxfLZw/r6Ge65xyY03mK/UfUqrVw1gFiFg==}
|
||||
'@codemirror/view@6.39.15':
|
||||
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
|
||||
|
||||
'@dokploy/trpc-openapi@0.0.4':
|
||||
resolution: {integrity: sha512-a7VKunKu9arq57bP9MPH7ikJuKfT5SILnNy70vMqf1stm5IrqMG3Y7CIFprFe0DZiw3bwjue0KpETIATBftN6w==}
|
||||
@@ -8858,17 +8858,17 @@ snapshots:
|
||||
|
||||
'@codemirror/autocomplete@6.18.6':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.0
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.29.0
|
||||
'@lezer/common': 1.2.3
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/common': 1.5.1
|
||||
|
||||
'@codemirror/commands@6.8.1':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.0
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.29.0
|
||||
'@lezer/common': 1.2.3
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/common': 1.5.1
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
dependencies:
|
||||
@@ -8895,12 +8895,12 @@ snapshots:
|
||||
|
||||
'@codemirror/language@6.11.0':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.29.0
|
||||
'@lezer/common': 1.2.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@lezer/lr': 1.4.2
|
||||
style-mod: 4.1.2
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/common': 1.5.1
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
style-mod: 4.1.3
|
||||
|
||||
'@codemirror/legacy-modes@6.4.0':
|
||||
dependencies:
|
||||
@@ -8924,18 +8924,12 @@ snapshots:
|
||||
|
||||
'@codemirror/theme-one-dark@6.1.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.0
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.29.0
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/highlight': 1.2.3
|
||||
|
||||
'@codemirror/view@6.29.0':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
style-mod: 4.1.2
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@codemirror/view@6.36.8':
|
||||
'@codemirror/view@6.39.15':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
style-mod: 4.1.2
|
||||
@@ -12263,39 +12257,39 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 20.17.51
|
||||
|
||||
'@uiw/codemirror-extensions-basic-setup@4.23.12(@codemirror/autocomplete@6.18.6)(@codemirror/commands@6.8.1)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)':
|
||||
'@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.18.6
|
||||
'@codemirror/commands': 6.8.1
|
||||
'@codemirror/language': 6.11.0
|
||||
'@codemirror/lint': 6.8.5
|
||||
'@codemirror/search': 6.5.11
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.29.0
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/commands': 6.10.2
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/lint': 6.9.4
|
||||
'@codemirror/search': 6.6.0
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
|
||||
'@uiw/codemirror-theme-github@4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)':
|
||||
'@uiw/codemirror-theme-github@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
|
||||
dependencies:
|
||||
'@uiw/codemirror-themes': 4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)
|
||||
'@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
|
||||
transitivePeerDependencies:
|
||||
- '@codemirror/language'
|
||||
- '@codemirror/state'
|
||||
- '@codemirror/view'
|
||||
|
||||
'@uiw/codemirror-themes@4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)':
|
||||
'@uiw/codemirror-themes@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.0
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.29.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
|
||||
'@uiw/react-codemirror@4.23.12(@babel/runtime@7.27.3)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.29.0)(codemirror@6.0.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
'@uiw/react-codemirror@4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.15)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.3
|
||||
'@codemirror/commands': 6.8.1
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/theme-one-dark': 6.1.2
|
||||
'@codemirror/view': 6.29.0
|
||||
'@uiw/codemirror-extensions-basic-setup': 4.23.12(@codemirror/autocomplete@6.18.6)(@codemirror/commands@6.8.1)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)
|
||||
codemirror: 6.0.1
|
||||
'@babel/runtime': 7.28.6
|
||||
'@codemirror/commands': 6.10.2
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/theme-one-dark': 6.1.3
|
||||
'@codemirror/view': 6.39.15
|
||||
'@uiw/codemirror-extensions-basic-setup': 4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
|
||||
codemirror: 6.0.2
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
@@ -12871,13 +12865,13 @@ snapshots:
|
||||
|
||||
codemirror@6.0.1:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.18.6
|
||||
'@codemirror/commands': 6.8.1
|
||||
'@codemirror/language': 6.11.0
|
||||
'@codemirror/lint': 6.8.5
|
||||
'@codemirror/search': 6.5.11
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.29.0
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/commands': 6.10.2
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/lint': 6.9.4
|
||||
'@codemirror/search': 6.6.0
|
||||
'@codemirror/state': 6.5.4
|
||||
'@codemirror/view': 6.39.15
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user