mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-22 14:25:24 +02:00
* fix(migrate-auth-secret): exit cleanly when there are no 2FA records The empty-records branch of `main()` returned without calling `process.exit(0)`, leaving the Drizzle Postgres connection pool holding the event loop open. The `migrate-auth-secret` process then hangs indefinitely after printing "No 2FA records found, nothing to migrate." causing the upstream `0.29.3.sh` security migration script (which calls this via `docker exec`) to never reach its final `docker service update` step that mounts the new Docker Secret. Operators end up with the new secret created but the dokploy service still configured with the hardcoded `BETTER_AUTH_SECRET`, while believing the migration completed. Match the success branch a few lines below which already does `process.exit(0)`, and the pattern used in sibling scripts `reset-password.ts` and `reset-2fa.ts`. Closes #4392 * feat(compose): add import from base64 in create service dropdown Adds an "Import" option to the Create Service dropdown that lets users paste a base64-encoded compose export, preview the template (compose YAML, domains, envs, mounts) before confirming, and create the service only on confirm. Adds a `previewTemplate` tRPC procedure that processes the base64 without touching the DB, with server access validation via session. * [autofix.ci] apply automated fixes * Enhance version synchronization workflow to include SDK repository - Updated the GitHub Actions workflow to sync versioning across MCP, CLI, and SDK repositories. - Added steps to bump the version in the SDK repository and regenerate tools from the latest OpenAPI spec. - Improved commit message formatting to include source and release information for all repositories. - Ensured successful synchronization messages for each repository after the version update. * feat(deployment): add readLogs procedure to fetch deployment logs - Introduced a new `readLogs` procedure that allows users to retrieve logs for a specific deployment by providing the deployment ID and an optional tail parameter. - Implemented permission checks to ensure users have access to the requested logs. - Enhanced log retrieval for both cloud and non-cloud environments, utilizing appropriate commands based on the server context. Resolve https://github.com/Dokploy/mcp/issues/14 * feat(deployment): add server access validation for deployment actions - Implemented server access validation in deployment procedures to ensure users can only access deployments associated with their active organization. - Added checks to throw an UNAUTHORIZED error if a user attempts to access a deployment linked to a server outside their organization. This enhancement improves security and access control within the deployment management system. * feat(organization): prevent inviting users with owner role - Added validation to prevent users from being invited with the owner role in the organization and user routers. - Implemented TRPCError responses to ensure proper error handling when attempting to assign the owner role. This change enhances role management and security within the organization structure. https://github.com/Dokploy/dokploy/security/advisories/GHSA-fm9p-wmpw-gxjh * feat(user): implement session cleanup on user update - Added functionality to delete old sessions when a user updates their password, ensuring that only the current session remains active. - This change enhances security by preventing unauthorized access from previous sessions after a password change. Close here https://github.com/Dokploy/dokploy/security/advisories/GHSA-rr9m-w87g-46f3 * feat(settings): add copy button to server IP in web server settings (#4397) * fix: copy Dokploy server IP when clicking server badge (#4390) * fix: copy Dokploy server IP when clicking server badge When a service runs on the local Dokploy server (no remote server), clicking the server badge did nothing because `data.server` is null. Now falls back to the server IP from settings so the badge always copies an IP address. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(copy-ip): implement IP address copying functionality across database service components - Added the ability to copy the server IP address to the clipboard when clicking the server badge in various database service components (Libsql, MariaDB, MongoDB, MySQL, PostgreSQL, Redis). - Integrated the `copy-to-clipboard` library and `sonner` for user feedback upon successful copy action. - Ensured fallback to the server IP from settings when the service data is not available, enhancing user experience and functionality. --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Mauricio Siu <siumauricio@icloud.com> * fix: responsive layout (#4391) Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com> * fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382) * fix: allow square brackets in zip path validation for Next.js dynamic routes (#4468) * fix: allow square brackets in zip drop path validation for Next.js dynamic routes ZIP uploads containing Next.js dynamic route files (e.g. app/api/[id]/route.ts, pages/[slug].tsx) were rejected by readValidDirectory because the path regex did not include square bracket characters. * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * fix: prevent webhook deploy crash when commit data lacks modified files (#4470) shouldDeploy passed undefined/null entries from commit.modified straight into micromatch, which throws "Expected input to be a string" and fails every webhook deployment when watch paths are configured. Filter out non-string values before matching. * fix: add type="button" to TooltipTrigger in form components to prevent accidental submission (#4422) Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com> * fix: enable comment toggle shortcut in env variable editor (#4402) (#4473) * fix: add tls=true label for domains when certificateType is none (#4018) (#4474) * fix: add tls=true label for compose domains when certificateType is none (#4018) * test: cover tls=true label for certificateType none, require https * fix: scope tls fix to compose labels, leave traefik file config unchanged (#4018) * chore: update version to v0.29.5 in package.json --------- Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com> Co-authored-by: ngenohkevin <ngenohkevin19@gmail.com> Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Co-authored-by: Mauricio Siu <siumauricio@icloud.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Volodymyr Kravchuk <volodymyr.kravch@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Nahidujjaman Hridoy <75487507+nhridoy@users.noreply.github.com> Co-authored-by: Francis <9560564+Baker@users.noreply.github.com> Co-authored-by: mixelburg <52622705+mixelburg@users.noreply.github.com> Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
433 lines
11 KiB
TypeScript
433 lines
11 KiB
TypeScript
import copy from "copy-to-clipboard";
|
|
import {
|
|
Check,
|
|
Copy,
|
|
Download as DownloadIcon,
|
|
Loader2,
|
|
Pause,
|
|
Play,
|
|
} from "lucide-react";
|
|
import React, { useEffect, useRef } from "react";
|
|
import { AlertBlock } from "@/components/shared/alert-block";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { api } from "@/utils/api";
|
|
import { AnalyzeLogs } from "./analyze-logs";
|
|
import { LineCountFilter } from "./line-count-filter";
|
|
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
|
import { StatusLogsFilter } from "./status-logs-filter";
|
|
import { TerminalLine } from "./terminal-line";
|
|
import { getLogType, type LogLine, parseLogs } from "./utils";
|
|
|
|
interface Props {
|
|
containerId: string;
|
|
serverId?: string | null;
|
|
runType: "swarm" | "native";
|
|
}
|
|
|
|
export const priorities = [
|
|
{
|
|
label: "Info",
|
|
value: "info",
|
|
},
|
|
{
|
|
label: "Success",
|
|
value: "success",
|
|
},
|
|
{
|
|
label: "Warning",
|
|
value: "warning",
|
|
},
|
|
{
|
|
label: "Debug",
|
|
value: "debug",
|
|
},
|
|
{
|
|
label: "Error",
|
|
value: "error",
|
|
},
|
|
];
|
|
|
|
export const DockerLogsId: React.FC<Props> = ({
|
|
containerId,
|
|
serverId,
|
|
runType,
|
|
}) => {
|
|
const { data } = api.docker.getConfig.useQuery(
|
|
{
|
|
containerId,
|
|
serverId: serverId ?? undefined,
|
|
},
|
|
{
|
|
enabled: !!containerId,
|
|
},
|
|
);
|
|
|
|
const [rawLogs, setRawLogs] = React.useState("");
|
|
const [filteredLogs, setFilteredLogs] = React.useState<LogLine[]>([]);
|
|
const [autoScroll, setAutoScroll] = React.useState(true);
|
|
const [lines, setLines] = React.useState<number>(100);
|
|
const [search, setSearch] = React.useState<string>("");
|
|
const [showTimestamp, setShowTimestamp] = React.useState(true);
|
|
const [since, setSince] = React.useState<TimeFilter>("all");
|
|
const [typeFilter, setTypeFilter] = React.useState<string[]>([]);
|
|
const [isPaused, setIsPaused] = React.useState(false);
|
|
const [messageBuffer, setMessageBuffer] = React.useState<string[]>([]);
|
|
const isPausedRef = useRef(false);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
|
const [copied, setCopied] = React.useState(false);
|
|
|
|
const scrollToBottom = () => {
|
|
if (autoScroll && scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
};
|
|
|
|
const handleScroll = () => {
|
|
if (!scrollRef.current) return;
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
|
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
|
setAutoScroll(isAtBottom);
|
|
};
|
|
|
|
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setSearch(e.target.value || "");
|
|
};
|
|
|
|
const handleLines = (lines: number) => {
|
|
setRawLogs("");
|
|
setFilteredLogs([]);
|
|
setMessageBuffer([]);
|
|
setLines(lines);
|
|
};
|
|
|
|
const handleSince = (value: TimeFilter) => {
|
|
setRawLogs("");
|
|
setFilteredLogs([]);
|
|
setMessageBuffer([]);
|
|
setSince(value);
|
|
};
|
|
|
|
const handlePauseResume = () => {
|
|
if (isPaused) {
|
|
// Resume: Apply all buffered messages
|
|
if (messageBuffer.length > 0) {
|
|
const bufferedContent = messageBuffer.join("");
|
|
setRawLogs((prev) => {
|
|
const updated = prev + bufferedContent;
|
|
const splitLines = updated.split("\n");
|
|
if (splitLines.length > lines) {
|
|
return splitLines.slice(-lines).join("\n");
|
|
}
|
|
return updated;
|
|
});
|
|
setMessageBuffer([]);
|
|
}
|
|
}
|
|
const newPausedState = !isPaused;
|
|
setIsPaused(newPausedState);
|
|
isPausedRef.current = newPausedState;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!containerId) return;
|
|
|
|
let isCurrentConnection = true;
|
|
let noDataTimeout: NodeJS.Timeout;
|
|
setIsLoading(true);
|
|
setRawLogs("");
|
|
setFilteredLogs([]);
|
|
setMessageBuffer([]);
|
|
// Reset pause state when container changes
|
|
setIsPaused(false);
|
|
isPausedRef.current = false;
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const params = new globalThis.URLSearchParams({
|
|
containerId,
|
|
tail: lines.toString(),
|
|
since,
|
|
search,
|
|
runType,
|
|
});
|
|
|
|
if (serverId) {
|
|
params.append("serverId", serverId);
|
|
}
|
|
|
|
const wsUrl = `${protocol}//${
|
|
window.location.host
|
|
}/docker-container-logs?${params.toString()}`;
|
|
const ws = new WebSocket(wsUrl);
|
|
|
|
const resetNoDataTimeout = () => {
|
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
|
noDataTimeout = setTimeout(() => {
|
|
if (isCurrentConnection) {
|
|
setIsLoading(false);
|
|
}
|
|
}, 2000); // Wait 2 seconds for data before showing "No logs found"
|
|
};
|
|
|
|
ws.onopen = () => {
|
|
if (!isCurrentConnection) {
|
|
ws.close();
|
|
return;
|
|
}
|
|
resetNoDataTimeout();
|
|
};
|
|
|
|
ws.onmessage = (e) => {
|
|
if (!isCurrentConnection) return;
|
|
|
|
if (isPausedRef.current) {
|
|
// When paused, buffer the messages instead of displaying them
|
|
setMessageBuffer((prev) => [...prev, e.data]);
|
|
} else {
|
|
// When not paused, display messages normally
|
|
setRawLogs((prev) => {
|
|
const updated = prev + e.data;
|
|
const splitLines = updated.split("\n");
|
|
if (splitLines.length > lines) {
|
|
return splitLines.slice(-lines).join("\n");
|
|
}
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
setIsLoading(false);
|
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
if (!isCurrentConnection) return;
|
|
console.error("WebSocket error:", error);
|
|
setIsLoading(false);
|
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
|
};
|
|
|
|
ws.onclose = (e) => {
|
|
if (!isCurrentConnection) return;
|
|
console.log("WebSocket closed:", e.reason);
|
|
setIsLoading(false);
|
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
|
};
|
|
|
|
return () => {
|
|
isCurrentConnection = false;
|
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.close();
|
|
}
|
|
};
|
|
}, [containerId, serverId, lines, search, since]);
|
|
|
|
const handleDownload = () => {
|
|
const logContent = filteredLogs
|
|
.map(
|
|
({ timestamp, message }: { timestamp: Date | null; message: string }) =>
|
|
`${timestamp?.toISOString() || "No timestamp"} ${message}`,
|
|
)
|
|
.join("\n");
|
|
|
|
const blob = new Blob([logContent], { type: "text/plain" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
const appName = data.Name.replace("/", "") || "app";
|
|
const isoDate = new Date().toISOString();
|
|
a.href = url;
|
|
a.download = `${appName}-${isoDate.slice(0, 10).replace(/-/g, "")}_${isoDate
|
|
.slice(11, 19)
|
|
.replace(/:/g, "")}.log.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleCopy = async () => {
|
|
const logContent = filteredLogs
|
|
.map(
|
|
({
|
|
timestamp,
|
|
message,
|
|
}: {
|
|
timestamp: Date | null;
|
|
message: string;
|
|
}) =>
|
|
showTimestamp
|
|
? `${timestamp?.toISOString() || "No timestamp"} ${message}`
|
|
: message,
|
|
)
|
|
.join("\n");
|
|
|
|
const success = copy(logContent);
|
|
if (success) {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
const handleFilter = (logs: LogLine[]) => {
|
|
return logs.filter((log) => {
|
|
const logType = getLogType(log.message).type;
|
|
|
|
if (typeFilter.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
return typeFilter.includes(logType);
|
|
});
|
|
};
|
|
|
|
// Sync isPausedRef with isPaused state
|
|
useEffect(() => {
|
|
isPausedRef.current = isPaused;
|
|
}, [isPaused]);
|
|
|
|
useEffect(() => {
|
|
setRawLogs("");
|
|
setFilteredLogs([]);
|
|
setMessageBuffer([]);
|
|
}, [containerId]);
|
|
|
|
useEffect(() => {
|
|
const logs = parseLogs(rawLogs);
|
|
const filtered = handleFilter(logs);
|
|
setFilteredLogs(filtered);
|
|
}, [rawLogs, search, lines, since, typeFilter]);
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
|
|
if (autoScroll && scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
}, [filteredLogs, autoScroll]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="rounded-lg">
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap justify-between items-start sm:items-center gap-4">
|
|
<div className="flex flex-wrap gap-4">
|
|
<LineCountFilter value={lines} onValueChange={handleLines} />
|
|
|
|
<SinceLogsFilter
|
|
value={since}
|
|
onValueChange={handleSince}
|
|
showTimestamp={showTimestamp}
|
|
onTimestampChange={setShowTimestamp}
|
|
/>
|
|
|
|
<StatusLogsFilter
|
|
value={typeFilter}
|
|
setValue={setTypeFilter}
|
|
title="Log type"
|
|
options={priorities}
|
|
/>
|
|
|
|
<Input
|
|
type="search"
|
|
placeholder="Search logs..."
|
|
value={search}
|
|
onChange={handleSearch}
|
|
className="inline-flex h-9 text-sm placeholder-gray-400 w-full sm:w-auto"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9"
|
|
onClick={handlePauseResume}
|
|
title={isPaused ? "Resume logs" : "Pause logs"}
|
|
>
|
|
{isPaused ? (
|
|
<Play className="size-4" />
|
|
) : (
|
|
<Pause className="size-4" />
|
|
)}
|
|
<span className="hidden lg:ml-2 lg:inline">
|
|
{isPaused ? "Resume" : "Pause"}
|
|
</span>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9"
|
|
onClick={handleCopy}
|
|
disabled={filteredLogs.length === 0}
|
|
title="Copy logs to clipboard"
|
|
>
|
|
{copied ? (
|
|
<Check className="size-4" />
|
|
) : (
|
|
<Copy className="size-4" />
|
|
)}
|
|
<span className="hidden lg:ml-2 lg:inline">
|
|
{copied ? "Copied" : "Copy"}
|
|
</span>
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 sm:w-auto w-full"
|
|
onClick={handleDownload}
|
|
disabled={filteredLogs.length === 0 || !data?.Name}
|
|
title="Download logs as text file"
|
|
>
|
|
<DownloadIcon className="size-4" />
|
|
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
|
|
</Button>
|
|
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
|
</div>
|
|
</div>
|
|
{isPaused && (
|
|
<AlertBlock type="warning" className="items-center">
|
|
<div className="flex items-center gap-2">
|
|
<Pause className="size-4" />
|
|
<span>
|
|
Logs paused
|
|
{messageBuffer.length > 0 && (
|
|
<span className="ml-1 font-medium">
|
|
({messageBuffer.length} messages buffered)
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
</AlertBlock>
|
|
)}
|
|
<div
|
|
ref={scrollRef}
|
|
onScroll={handleScroll}
|
|
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
|
>
|
|
{filteredLogs.length > 0 ? (
|
|
filteredLogs.map((filteredLog: LogLine, index: number) => (
|
|
<TerminalLine
|
|
key={`${filteredLog.rawTimestamp ?? ""}-${index}`}
|
|
log={filteredLog}
|
|
searchTerm={search}
|
|
noTimestamp={!showTimestamp}
|
|
/>
|
|
))
|
|
) : isLoading ? (
|
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
|
<Loader2 className="h-6 w-6 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
|
No logs found
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|