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>;
|
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 = ({
|
export const RestoreBackup = ({
|
||||||
databaseId,
|
databaseId,
|
||||||
databaseType,
|
databaseType,
|
||||||
@@ -101,7 +109,7 @@ export const RestoreBackup = ({
|
|||||||
|
|
||||||
const debouncedSetSearch = debounce((value: string) => {
|
const debouncedSetSearch = debounce((value: string) => {
|
||||||
setDebouncedSearchTerm(value);
|
setDebouncedSearchTerm(value);
|
||||||
}, 150);
|
}, 350);
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const handleSearchChange = (value: string) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
@@ -271,7 +279,7 @@ export const RestoreBackup = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Popover>
|
<Popover modal>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
@@ -308,28 +316,51 @@ export const RestoreBackup = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-64">
|
<ScrollArea className="h-64">
|
||||||
<CommandGroup>
|
<CommandGroup className="w-96">
|
||||||
{files.map((file) => (
|
{files?.map((file) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={file}
|
value={file.Path}
|
||||||
key={file}
|
key={file.Path}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("backupFile", file);
|
form.setValue("backupFile", file.Path);
|
||||||
setSearch(file);
|
if (file.IsDir) {
|
||||||
setDebouncedSearchTerm(file);
|
setSearch(`${file.Path}/`);
|
||||||
|
setDebouncedSearchTerm(`${file.Path}/`);
|
||||||
|
} else {
|
||||||
|
setSearch(file.Path);
|
||||||
|
setDebouncedSearchTerm(file.Path);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
<div className="flex w-full justify-between">
|
<div className="flex w-full justify-between">
|
||||||
<span>{file}</span>
|
<span className="font-medium">
|
||||||
</div>
|
{file.Path}
|
||||||
|
</span>
|
||||||
|
|
||||||
<CheckIcon
|
<CheckIcon
|
||||||
className={cn(
|
className={cn(
|
||||||
"ml-auto h-4 w-4",
|
"ml-auto h-4 w-4",
|
||||||
file === field.value
|
file.Path === field.value
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0",
|
: "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>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -13,22 +13,31 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
|||||||
bytes[i] = binaryStr.charCodeAt(i);
|
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++) {
|
for (let i = 0; i < bytes.length - 2; i++) {
|
||||||
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
|
// Look for sequence containing validity period (0x30)
|
||||||
const dateType = bytes[i];
|
if (bytes[i] === 0x30) {
|
||||||
const dateLength = bytes[i + 1];
|
// Check next bytes for UTCTime or GeneralizedTime
|
||||||
if (typeof dateLength === "undefined") continue;
|
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) {
|
if (!dateFound) {
|
||||||
dateFound++;
|
// Skip "not before" date
|
||||||
i += dateLength + 1;
|
dateFound = true;
|
||||||
|
j += dateLength + 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Found "not after" date
|
||||||
let dateStr = "";
|
let dateStr = "";
|
||||||
for (let j = 0; j < dateLength; j++) {
|
for (let k = 0; k < dateLength; k++) {
|
||||||
const charCode = bytes[i + 2 + j];
|
const charCode = bytes[j + 2 + k];
|
||||||
if (typeof charCode === "undefined") continue;
|
if (typeof charCode === "undefined") continue;
|
||||||
dateStr += String.fromCharCode(charCode);
|
dateStr += String.fromCharCode(charCode);
|
||||||
}
|
}
|
||||||
@@ -61,6 +70,9 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -26,15 +26,20 @@ const dockerComposeServices = [
|
|||||||
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
||||||
].map((opt) => ({
|
].map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
apply: (view: EditorView, completion: Completion) => {
|
apply: (
|
||||||
|
view: EditorView,
|
||||||
|
completion: Completion,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
) => {
|
||||||
const insert = `${completion.label}:`;
|
const insert = `${completion.label}:`;
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
from: view.state.selection.main.from,
|
from,
|
||||||
to: view.state.selection.main.to,
|
to,
|
||||||
insert,
|
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" },
|
{ label: "networks", type: "keyword", info: "Networks to join" },
|
||||||
].map((opt) => ({
|
].map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
apply: (view: EditorView, completion: Completion) => {
|
apply: (
|
||||||
|
view: EditorView,
|
||||||
|
completion: Completion,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
) => {
|
||||||
const insert = `${completion.label}: `;
|
const insert = `${completion.label}: `;
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
from: view.state.selection.main.from,
|
from,
|
||||||
to: view.state.selection.main.to,
|
to,
|
||||||
insert,
|
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 line = context.state.doc.lineAt(context.pos);
|
||||||
const indentation = /^\s*/.exec(line.text)?.[0].length || 0;
|
const indentation = /^\s*/.exec(line.text)?.[0].length || 0;
|
||||||
|
|
||||||
|
// If we're at the root level
|
||||||
if (indentation === 0) {
|
if (indentation === 0) {
|
||||||
return {
|
return {
|
||||||
from: word.from,
|
from: word.from,
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
import { z } from "zod";
|
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({
|
export const backupRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(apiCreateBackup)
|
.input(apiCreateBackup)
|
||||||
@@ -268,7 +280,7 @@ export const backupRouter = createTRPCRouter({
|
|||||||
: input.search;
|
: input.search;
|
||||||
|
|
||||||
const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath;
|
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 = "";
|
let stdout = "";
|
||||||
|
|
||||||
@@ -280,20 +292,35 @@ export const backupRouter = createTRPCRouter({
|
|||||||
stdout = result.stdout;
|
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
|
const results = baseDir
|
||||||
? files.map((file) => `${baseDir}${file}`)
|
? files.map((file) => ({
|
||||||
|
...file,
|
||||||
|
Path: `${baseDir}${file.Path}`,
|
||||||
|
}))
|
||||||
: files;
|
: files;
|
||||||
|
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
return results.filter((file) =>
|
return results
|
||||||
file.toLowerCase().includes(searchTerm.toLowerCase()),
|
.filter((file) =>
|
||||||
);
|
file.Path.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
)
|
||||||
|
.slice(0, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results.slice(0, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error in listBackupFiles:", error);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -25,21 +25,23 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
|||||||
|
|
||||||
// First get the container ID
|
// First get the container ID
|
||||||
const { stdout: containerId } = await execAsync(
|
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) {
|
if (!containerId) {
|
||||||
throw new Error("PostgreSQL container not found");
|
throw new Error("PostgreSQL container not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then run pg_dump with the container ID
|
const postgresContainerId = containerId.trim();
|
||||||
const postgresCommand = `docker exec ${containerId.trim()} pg_dump -v -Fc -U dokploy -d dokploy > '${tempDir}/database.sql'`;
|
|
||||||
|
const postgresCommand = `docker exec ${postgresContainerId} pg_dump -v -Fc -U dokploy -d dokploy > '${tempDir}/database.sql'`;
|
||||||
await execAsync(postgresCommand);
|
await execAsync(postgresCommand);
|
||||||
|
|
||||||
await execAsync(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`);
|
await execAsync(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`);
|
||||||
|
|
||||||
await execAsync(
|
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}"`;
|
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");
|
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
|
// Drop and recreate database
|
||||||
emit("Disconnecting all users from database...");
|
emit("Disconnecting all users from database...");
|
||||||
await execAsync(
|
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...");
|
emit("Dropping existing database...");
|
||||||
await execAsync(
|
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...");
|
emit("Creating fresh database...");
|
||||||
await execAsync(
|
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
|
// Copy the backup file into the container
|
||||||
emit("Copying backup file into container...");
|
emit("Copying backup file into container...");
|
||||||
await execAsync(
|
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
|
// Verify file in container
|
||||||
emit("Verifying file in container...");
|
emit("Verifying file in container...");
|
||||||
await execAsync(
|
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
|
// Restore from the copied file
|
||||||
emit("Running database restore...");
|
emit("Running database restore...");
|
||||||
await execAsync(
|
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
|
// Cleanup the temporary file in the container
|
||||||
emit("Cleaning up container temp file...");
|
emit("Cleaning up container temp file...");
|
||||||
await execAsync(
|
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!");
|
emit("Restore completed successfully!");
|
||||||
|
|||||||
Reference in New Issue
Block a user