mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Compare commits
27 Commits
1761-unabl
...
1730-pg_re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef874ecd4 | ||
|
|
d6daa5677a | ||
|
|
dc03ba73b3 | ||
|
|
5c2159f7b2 | ||
|
|
ffcdbcf046 | ||
|
|
c0b35efaca | ||
|
|
22dee88e51 | ||
|
|
79796185d6 | ||
|
|
461d7c530a | ||
|
|
8d28a50a17 | ||
|
|
da60c4f3a8 | ||
|
|
764f8ec993 | ||
|
|
ef7918a33a | ||
|
|
af4511040f | ||
|
|
1bbbdfba60 | ||
|
|
116e33ce37 | ||
|
|
e9b92d2641 | ||
|
|
3e07be38df | ||
|
|
b1d1763988 | ||
|
|
b5d199057d | ||
|
|
bfb6baf572 | ||
|
|
1f81794904 | ||
|
|
d5d3831d54 | ||
|
|
856399550a | ||
|
|
86b8b0987b | ||
|
|
0dac1fefe6 | ||
|
|
633ba899e0 |
@@ -77,6 +77,14 @@ const RestoreBackupSchema = z.object({
|
||||
|
||||
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
export const RestoreBackup = ({
|
||||
databaseId,
|
||||
databaseType,
|
||||
@@ -101,7 +109,7 @@ export const RestoreBackup = ({
|
||||
|
||||
const debouncedSetSearch = debounce((value: string) => {
|
||||
setDebouncedSearchTerm(value);
|
||||
}, 150);
|
||||
}, 350);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
@@ -271,7 +279,7 @@ export const RestoreBackup = ({
|
||||
</Badge>
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<Popover modal>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
@@ -308,28 +316,51 @@ export const RestoreBackup = ({
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<CommandGroup>
|
||||
{files.map((file) => (
|
||||
<CommandGroup className="w-96">
|
||||
{files?.map((file) => (
|
||||
<CommandItem
|
||||
value={file}
|
||||
key={file}
|
||||
value={file.Path}
|
||||
key={file.Path}
|
||||
onSelect={() => {
|
||||
form.setValue("backupFile", file);
|
||||
setSearch(file);
|
||||
setDebouncedSearchTerm(file);
|
||||
form.setValue("backupFile", file.Path);
|
||||
if (file.IsDir) {
|
||||
setSearch(`${file.Path}/`);
|
||||
setDebouncedSearchTerm(`${file.Path}/`);
|
||||
} else {
|
||||
setSearch(file.Path);
|
||||
setDebouncedSearchTerm(file.Path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full justify-between">
|
||||
<span>{file}</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="font-medium">
|
||||
{file.Path}
|
||||
</span>
|
||||
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
file.Path === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Size: {formatBytes(file.Size)}
|
||||
</span>
|
||||
{file.IsDir && (
|
||||
<span className="text-blue-500">
|
||||
Directory
|
||||
</span>
|
||||
)}
|
||||
{file.Hashes?.MD5 && (
|
||||
<span>MD5: {file.Hashes.MD5}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
file === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
@@ -13,53 +13,65 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
||||
bytes[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
let dateFound = 0;
|
||||
// ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18
|
||||
// We need to find the second occurrence of either tag as it's the "not after" (expiration) date
|
||||
let dateFound = false;
|
||||
for (let i = 0; i < bytes.length - 2; i++) {
|
||||
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
|
||||
const dateType = bytes[i];
|
||||
const dateLength = bytes[i + 1];
|
||||
if (typeof dateLength === "undefined") continue;
|
||||
// Look for sequence containing validity period (0x30)
|
||||
if (bytes[i] === 0x30) {
|
||||
// Check next bytes for UTCTime or GeneralizedTime
|
||||
let j = i + 1;
|
||||
while (j < bytes.length - 2) {
|
||||
if (bytes[j] === 0x17 || bytes[j] === 0x18) {
|
||||
const dateType = bytes[j];
|
||||
const dateLength = bytes[j + 1];
|
||||
if (typeof dateLength === "undefined") break;
|
||||
|
||||
if (dateFound === 0) {
|
||||
dateFound++;
|
||||
i += dateLength + 1;
|
||||
continue;
|
||||
if (!dateFound) {
|
||||
// Skip "not before" date
|
||||
dateFound = true;
|
||||
j += dateLength + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found "not after" date
|
||||
let dateStr = "";
|
||||
for (let k = 0; k < dateLength; k++) {
|
||||
const charCode = bytes[j + 2 + k];
|
||||
if (typeof charCode === "undefined") continue;
|
||||
dateStr += String.fromCharCode(charCode);
|
||||
}
|
||||
|
||||
if (dateType === 0x17) {
|
||||
// UTCTime (YYMMDDhhmmssZ)
|
||||
const year = Number.parseInt(dateStr.slice(0, 2));
|
||||
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
fullYear,
|
||||
Number.parseInt(dateStr.slice(2, 4)) - 1,
|
||||
Number.parseInt(dateStr.slice(4, 6)),
|
||||
Number.parseInt(dateStr.slice(6, 8)),
|
||||
Number.parseInt(dateStr.slice(8, 10)),
|
||||
Number.parseInt(dateStr.slice(10, 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// GeneralizedTime (YYYYMMDDhhmmssZ)
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
Number.parseInt(dateStr.slice(0, 4)),
|
||||
Number.parseInt(dateStr.slice(4, 6)) - 1,
|
||||
Number.parseInt(dateStr.slice(6, 8)),
|
||||
Number.parseInt(dateStr.slice(8, 10)),
|
||||
Number.parseInt(dateStr.slice(10, 12)),
|
||||
Number.parseInt(dateStr.slice(12, 14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
j++;
|
||||
}
|
||||
|
||||
let dateStr = "";
|
||||
for (let j = 0; j < dateLength; j++) {
|
||||
const charCode = bytes[i + 2 + j];
|
||||
if (typeof charCode === "undefined") continue;
|
||||
dateStr += String.fromCharCode(charCode);
|
||||
}
|
||||
|
||||
if (dateType === 0x17) {
|
||||
// UTCTime (YYMMDDhhmmssZ)
|
||||
const year = Number.parseInt(dateStr.slice(0, 2));
|
||||
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
fullYear,
|
||||
Number.parseInt(dateStr.slice(2, 4)) - 1,
|
||||
Number.parseInt(dateStr.slice(4, 6)),
|
||||
Number.parseInt(dateStr.slice(6, 8)),
|
||||
Number.parseInt(dateStr.slice(8, 10)),
|
||||
Number.parseInt(dateStr.slice(10, 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// GeneralizedTime (YYYYMMDDhhmmssZ)
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
Number.parseInt(dateStr.slice(0, 4)),
|
||||
Number.parseInt(dateStr.slice(4, 6)) - 1,
|
||||
Number.parseInt(dateStr.slice(6, 8)),
|
||||
Number.parseInt(dateStr.slice(8, 10)),
|
||||
Number.parseInt(dateStr.slice(10, 12)),
|
||||
Number.parseInt(dateStr.slice(12, 14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -26,15 +26,20 @@ const dockerComposeServices = [
|
||||
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
||||
].map((opt) => ({
|
||||
...opt,
|
||||
apply: (view: EditorView, completion: Completion) => {
|
||||
apply: (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
) => {
|
||||
const insert = `${completion.label}:`;
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: view.state.selection.main.from,
|
||||
to: view.state.selection.main.to,
|
||||
from,
|
||||
to,
|
||||
insert,
|
||||
},
|
||||
selection: { anchor: view.state.selection.main.from + insert.length },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -74,15 +79,20 @@ const dockerComposeServiceOptions = [
|
||||
{ label: "networks", type: "keyword", info: "Networks to join" },
|
||||
].map((opt) => ({
|
||||
...opt,
|
||||
apply: (view: EditorView, completion: Completion) => {
|
||||
apply: (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
) => {
|
||||
const insert = `${completion.label}: `;
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: view.state.selection.main.from,
|
||||
to: view.state.selection.main.to,
|
||||
from,
|
||||
to,
|
||||
insert,
|
||||
},
|
||||
selection: { anchor: view.state.selection.main.from + insert.length },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -99,6 +109,7 @@ function dockerComposeComplete(
|
||||
const line = context.state.doc.lineAt(context.pos);
|
||||
const indentation = /^\s*/.exec(line.text)?.[0].length || 0;
|
||||
|
||||
// If we're at the root level
|
||||
if (indentation === 0) {
|
||||
return {
|
||||
from: word.from,
|
||||
|
||||
@@ -50,6 +50,18 @@ import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod";
|
||||
|
||||
interface RcloneFile {
|
||||
Path: string;
|
||||
Name: string;
|
||||
Size: number;
|
||||
IsDir: boolean;
|
||||
Tier?: string;
|
||||
Hashes?: {
|
||||
MD5?: string;
|
||||
SHA1?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const backupRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateBackup)
|
||||
@@ -268,7 +280,7 @@ export const backupRouter = createTRPCRouter({
|
||||
: input.search;
|
||||
|
||||
const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath;
|
||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} "${searchPath}" | head -n 100`;
|
||||
const listCommand = `rclone lsjson ${rcloneFlags.join(" ")} "${searchPath}" --no-mimetype --no-modtime 2>/dev/null`;
|
||||
|
||||
let stdout = "";
|
||||
|
||||
@@ -280,20 +292,35 @@ export const backupRouter = createTRPCRouter({
|
||||
stdout = result.stdout;
|
||||
}
|
||||
|
||||
const files = stdout.split("\n").filter(Boolean);
|
||||
let files: RcloneFile[] = [];
|
||||
try {
|
||||
files = JSON.parse(stdout) as RcloneFile[];
|
||||
} catch (error) {
|
||||
console.error("Error parsing JSON response:", error);
|
||||
console.error("Raw stdout:", stdout);
|
||||
throw new Error("Failed to parse backup files list");
|
||||
}
|
||||
|
||||
// Limit to first 100 files
|
||||
|
||||
const results = baseDir
|
||||
? files.map((file) => `${baseDir}${file}`)
|
||||
? files.map((file) => ({
|
||||
...file,
|
||||
Path: `${baseDir}${file.Path}`,
|
||||
}))
|
||||
: files;
|
||||
|
||||
if (searchTerm) {
|
||||
return results.filter((file) =>
|
||||
file.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
return results
|
||||
.filter((file) =>
|
||||
file.Path.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
return results;
|
||||
return results.slice(0, 100);
|
||||
} catch (error) {
|
||||
console.error("Error in listBackupFiles:", error);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
|
||||
@@ -25,21 +25,23 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
|
||||
// First get the container ID
|
||||
const { stdout: containerId } = await execAsync(
|
||||
"docker ps --filter 'name=dokploy-postgres' -q",
|
||||
`docker ps --filter "name=dokploy-postgres" --filter "status=running" -q | head -n 1`,
|
||||
);
|
||||
|
||||
if (!containerId) {
|
||||
throw new Error("PostgreSQL container not found");
|
||||
}
|
||||
|
||||
// Then run pg_dump with the container ID
|
||||
const postgresCommand = `docker exec ${containerId.trim()} pg_dump -v -Fc -U dokploy -d dokploy > '${tempDir}/database.sql'`;
|
||||
const postgresContainerId = containerId.trim();
|
||||
|
||||
const postgresCommand = `docker exec ${postgresContainerId} pg_dump -v -Fc -U dokploy -d dokploy > '${tempDir}/database.sql'`;
|
||||
await execAsync(postgresCommand);
|
||||
|
||||
await execAsync(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`);
|
||||
|
||||
await execAsync(
|
||||
`cd ${tempDir} && zip -r ${backupFileName} database.sql filesystem/ > /dev/null 2>&1`,
|
||||
// Zip all .sql files since we created more than one
|
||||
`cd ${tempDir} && zip -r ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`,
|
||||
);
|
||||
|
||||
const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`;
|
||||
|
||||
@@ -83,44 +83,54 @@ export const restoreWebServerBackup = async (
|
||||
throw new Error("Database file not found after extraction");
|
||||
}
|
||||
|
||||
const { stdout: postgresContainer } = await execAsync(
|
||||
`docker ps --filter "name=dokploy-postgres" --filter "status=running" -q | head -n 1`,
|
||||
);
|
||||
|
||||
if (!postgresContainer) {
|
||||
throw new Error("Dokploy Postgres container not found");
|
||||
}
|
||||
|
||||
const postgresContainerId = postgresContainer.trim();
|
||||
|
||||
// Drop and recreate database
|
||||
emit("Disconnecting all users from database...");
|
||||
await execAsync(
|
||||
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) psql -U dokploy postgres -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = 'dokploy' AND pid <> pg_backend_pid();"`,
|
||||
`docker exec ${postgresContainerId} psql -U dokploy postgres -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = 'dokploy' AND pid <> pg_backend_pid();"`,
|
||||
);
|
||||
|
||||
emit("Dropping existing database...");
|
||||
await execAsync(
|
||||
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) psql -U dokploy postgres -c "DROP DATABASE IF EXISTS dokploy;"`,
|
||||
`docker exec ${postgresContainerId} psql -U dokploy postgres -c "DROP DATABASE IF EXISTS dokploy;"`,
|
||||
);
|
||||
|
||||
emit("Creating fresh database...");
|
||||
await execAsync(
|
||||
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) psql -U dokploy postgres -c "CREATE DATABASE dokploy;"`,
|
||||
`docker exec ${postgresContainerId} psql -U dokploy postgres -c "CREATE DATABASE dokploy;"`,
|
||||
);
|
||||
|
||||
// Copy the backup file into the container
|
||||
emit("Copying backup file into container...");
|
||||
await execAsync(
|
||||
`docker cp ${tempDir}/database.sql $(docker ps --filter "name=dokploy-postgres" -q):/tmp/database.sql`,
|
||||
`docker cp ${tempDir}/database.sql ${postgresContainerId}:/tmp/database.sql`,
|
||||
);
|
||||
|
||||
// Verify file in container
|
||||
emit("Verifying file in container...");
|
||||
await execAsync(
|
||||
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) ls -l /tmp/database.sql`,
|
||||
`docker exec ${postgresContainerId} ls -l /tmp/database.sql`,
|
||||
);
|
||||
|
||||
// Restore from the copied file
|
||||
emit("Running database restore...");
|
||||
await execAsync(
|
||||
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) pg_restore -v -U dokploy -d dokploy /tmp/database.sql`,
|
||||
`docker exec ${postgresContainerId} pg_restore -v -U dokploy -d dokploy /tmp/database.sql`,
|
||||
);
|
||||
|
||||
// Cleanup the temporary file in the container
|
||||
emit("Cleaning up container temp file...");
|
||||
await execAsync(
|
||||
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) rm /tmp/database.sql`,
|
||||
`docker exec ${postgresContainerId} rm /tmp/database.sql`,
|
||||
);
|
||||
|
||||
emit("Restore completed successfully!");
|
||||
|
||||
Reference in New Issue
Block a user