Compare commits

...

57 Commits

Author SHA1 Message Date
Mauricio Siu
c7f44f65bc feat(applications): add support for Docker build secrets
- Implement build secrets functionality for Dockerfile builds
- Add new `buildSecrets` field to application schema
- Update UI and backend to handle build-time secrets
- Modify Docker build process to support secret injection during build
2025-03-08 19:06:14 -06:00
Mauricio Siu
62bd8e3c95 feat(services): add role-based delete service permissions
- Restrict bulk delete action to owners and users with delete service permissions
- Conditionally render delete button based on user role and authorization
- Improve service management security by implementing fine-grained access control
2025-03-08 18:51:59 -06:00
Mauricio Siu
85734c0a24 Merge pull request #1437 from Dokploy/700-reorganize-services-order
700 reorganize services order
2025-03-08 18:50:26 -06:00
Mauricio Siu
8d18aeda45 refactor(ui): improve responsive layout for project services view
- Update responsive breakpoints for service list layout
- Use more semantic breakpoint classes (xl, lg) for better responsiveness
- Adjust flex direction and alignment for improved mobile and desktop views
2025-03-08 18:50:09 -06:00
Mauricio Siu
45923d3a1f feat(services): add sorting functionality for services
- Implement local storage-based sorting for services
- Add sorting options by name, type, and creation date
- Provide ascending and descending sort order selection
- Enhance service list usability with dynamic sorting
2025-03-08 18:48:34 -06:00
Mauricio Siu
043843f714 Merge pull request #1436 from Dokploy/feat/add-bulk-delete
feat(services): add bulk delete functionality for services
2025-03-08 18:43:54 -06:00
Mauricio Siu
7dda252b7c feat(services): add bulk delete functionality for services
- Implement bulk delete feature for applications, compose, and various database services
- Add delete mutation endpoints for each service type
- Provide user-friendly bulk delete action with error handling and success notifications
- Integrate Trash2 icon for delete action in bulk service management
2025-03-08 18:43:37 -06:00
Mauricio Siu
bf0668c319 Merge pull request #1435 from Dokploy/969-move-services-between-projects
feat(services): add bulk service move functionality across projects
2025-03-08 18:40:33 -06:00
Mauricio Siu
fc1dbcf51a feat(services): improve bulk move project selection UX
- Add empty state handling when no other projects are available
- Disable move button when no target projects exist
- Provide clear guidance for users to create a new project before moving services
2025-03-08 18:40:23 -06:00
Mauricio Siu
b34987530e feat(services): add bulk service move functionality across projects
- Implement service move feature for applications, compose, databases, and other services
- Add move dialog with project selection for bulk service transfer
- Create move mutation endpoints for each service type
- Enhance project management with cross-project service relocation
- Improve user experience with error handling and success notifications
2025-03-08 18:39:02 -06:00
Mauricio Siu
ff8d922f2b Merge pull request #1434 from Dokploy/1301-add-information-tooltips-to-buttons
feat(ui): add tooltips to service action buttons for improved user gu…
2025-03-08 18:27:46 -06:00
Mauricio Siu
01c33ad98b feat(ui): add tooltips to service action buttons for improved user guidance
- Integrate tooltips for Deploy, Rebuild, Start, and Stop buttons across various service components
- Provide context-specific explanations for each action button
- Enhance user understanding of service management actions
- Consistent tooltip styling and implementation using TooltipProvider
2025-03-08 18:26:39 -06:00
Mauricio Siu
9816ecaea1 Merge pull request #1433 from Dokploy/1334-increase-the-size-of-environment-window
refactor(ui): improve environment code editor styling and layout
2025-03-08 18:09:04 -06:00
Mauricio Siu
832fa526dd refactor(ui): improve environment code editor styling and layout
- Adjust CodeEditor component wrapper and class names
- Enhance font and styling for environment configuration
- Optimize form item and control rendering
2025-03-08 18:08:49 -06:00
Mauricio Siu
2a5eceb555 Merge pull request #1432 from Dokploy/1315-show-containers-sorted-by-name
refactor(docker): sort container lists by name
2025-03-08 17:56:49 -06:00
Mauricio Siu
08d7c4e1c3 refactor(docker): sort container lists by name
- Add sorting to container retrieval methods in docker service
- Ensure consistent container list ordering across different container fetching functions
- Improve readability and predictability of container list results
2025-03-08 17:56:20 -06:00
Mauricio Siu
c89f957133 refactor(ui): enhance update server button and sidebar layout
- Improve UpdateServer component with flexible rendering and tooltip support
- Modify sidebar layout to integrate update server button more cleanly
- Add conditional rendering and styling for update availability
- Introduce more consistent button and tooltip interactions
2025-03-08 15:31:08 -06:00
Mauricio Siu
8ba3a42c1e Merge pull request #1430 from Dokploy/1278-dokploy-oom-issue-on-requests-tab
feat(monitoring): add date range filtering and log cleanup scheduling
2025-03-08 14:56:17 -06:00
Mauricio Siu
2c3ff5794d refactor(user): update log cleanup configuration
- Replace enableLogRotation boolean with logCleanupCron configuration
- Align with recent log scheduling and monitoring improvements
2025-03-08 14:23:52 -06:00
Mauricio Siu
673e0a6880 feat(monitoring): add date range filtering and log cleanup scheduling
- Implement date range filtering for access logs and request statistics
- Add log cleanup scheduling with configurable cron expression
- Update UI components to support date range selection
- Refactor log processing and parsing to handle date filtering
- Add new database migration for log cleanup cron configuration
- Remove deprecated log rotation management logic
2025-03-08 14:20:27 -06:00
Mauricio Siu
b64ddf1119 Merge pull request #1392 from ali-issa/feature/project-view-tab-reorg
feat: reorganize project view tabs into logical workflow groups #1261
2025-03-08 12:36:46 -06:00
Mauricio Siu
2f074ac734 Merge pull request #1405 from frostming/patch-1
feat: fallback to openai compatible provider if url host doesn't match
2025-03-07 00:56:33 -06:00
Mauricio Siu
96e3721b4b chore(ai): remove debug logging in model fetching 2025-03-07 00:56:12 -06:00
Mauricio Siu
b8e5cae88f feat(ai): improve model fetching and error handling
- Add server-side model fetching endpoint with flexible provider support
- Refactor client-side AI settings component to use new API query
- Implement dynamic header generation for different AI providers
- Enhance error handling and toast notifications
- Remove local model fetching logic in favor of server-side implementation
2025-03-07 00:55:11 -06:00
Mauricio Siu
fa20444a14 Merge pull request #1414 from gentslava/fix/template-superset
fix(template): superset
2025-03-07 00:12:43 -06:00
Mauricio Siu
668ccabec8 Merge pull request #1390 from hexaaagon/feat/zipline
feat(zipline): update zipline version
2025-03-07 00:12:19 -06:00
Mauricio Siu
aa07a0c574 Merge pull request #1417 from vinumzz/vinumzz-remove-shadow-monitoring
refactor: remove unnecessary extra shadow from monitoring page
2025-03-07 00:10:22 -06:00
Mauricio Siu
0b64b43376 Merge pull request #1415 from gentslava/feature/template-datalens
feat(template): DataLens
2025-03-07 00:10:03 -06:00
Mauricio Siu
5c65dc9a21 Update apps/dokploy/templates/datalens/docker-compose.yml 2025-03-07 00:06:20 -06:00
Mauricio Siu
58262606d4 Update apps/dokploy/templates/datalens/docker-compose.yml 2025-03-07 00:06:16 -06:00
Mauricio Siu
f73959db41 Update apps/dokploy/templates/datalens/docker-compose.yml 2025-03-07 00:06:10 -06:00
Mauricio Siu
e6c664e65f Update apps/dokploy/templates/datalens/docker-compose.yml 2025-03-07 00:06:06 -06:00
Mauricio Siu
36cc157566 Merge pull request #1355 from drudge/templates/hoarder
feat: add Hoarder template
2025-03-07 00:03:42 -06:00
Mauricio Siu
7e070623cc Merge pull request #1411 from gentslava/canary
fix: breadcrumbs UX
2025-03-06 23:58:42 -06:00
Mauricio Siu
b2c0a685f8 fix(destinations): validate server selection for cloud destinations 2025-03-06 23:57:39 -06:00
Mauricio Siu
c14528886d Merge pull request #1424 from Dokploy/1382-automated-postgres-backup-always-empty
feat(destinations): add createdAt timestamp and display creation date
2025-03-06 23:54:03 -06:00
Mauricio Siu
29eb490e2d feat(destinations): add createdAt timestamp and display creation date 2025-03-06 23:46:21 -06:00
Mauricio Siu
6166963b00 fix(gitlab): remove debug console logs from connection testing 2025-03-06 22:27:30 -06:00
Mauricio Siu
f544efed35 Merge pull request #1422 from Dokploy/1418-fetching-gitlab-repositories-0-found
fix(gitlab): update repository filtering and connection testing
2025-03-06 22:17:34 -06:00
Mauricio Siu
598d095241 fix(gitlab): update repository filtering and connection testing
- Change repository filtering to use 'user' kind instead of 'member'
- Add console logging for debugging GitLab provider and repository connection
- Ensure consistent filtering logic in both getGitlabRepositories and testGitlabConnection
2025-03-06 22:17:20 -06:00
Mauricio Siu
457a8e05fd chore(issue-template): update bug report label emoji 2025-03-06 22:05:31 -06:00
Mauricio Siu
3ca057c44a chore(issue-template): update bug report label and add cloud version option 2025-03-06 22:04:46 -06:00
Nicholas Penree
ad3a0198e9 feat: add Hoarder template 2025-03-06 08:23:49 -05:00
Peter Vinum
ab5f62604c refactor: remove unnecessary extra shadow from monitoring page 2025-03-06 14:08:23 +01:00
Vyacheslav Shcherbinin
bf9e886b9a Disable demo 2025-03-06 14:14:01 +07:00
Vyacheslav Shcherbinin
f5cd0fbdd8 Restart policy 2025-03-06 11:32:13 +07:00
Vyacheslav Scherbinin
8859cc97b4 fix: superset docker-compose 2025-03-06 10:46:10 +07:00
Vyacheslav Scherbinin
3bdd5e4dd0 Template DataLens 2025-03-06 10:34:13 +07:00
Vyacheslav Scherbinin
b0c710aa92 Tab instead space 2025-03-05 21:25:11 +07:00
Vyacheslav Scherbinin
c83d0a95b7 Remove ending separator 2025-03-05 20:53:38 +07:00
Vyacheslav Scherbinin
71ca5babfd Remove duplicate breadcrumb 2025-03-05 20:51:09 +07:00
Vyacheslav Scherbinin
f342613503 Text format 2025-03-05 20:27:16 +07:00
Frost Ming
efd176451f fix: default case correct 2025-03-05 16:19:28 +08:00
Frost Ming
a7fd64e019 fix: use a named case 2025-03-05 16:17:50 +08:00
Frost Ming
21c8b98f9c feat: fallback to openai compatible provider if url host doesn't match 2025-03-05 16:12:46 +08:00
Ali Issa
6846e0e5a3 feat: reorganize project view tabs into logical workflow groups #1261
Restructure the project view tabs to follow a more intuitive user journey:
- Group tabs into "Initial Setup", "Deployment", and "Monitoring" sections
- Maintain "Advanced" as a standalone option
- Order tabs to match typical project workflow (configuration → deployment → monitoring)

This reorganization reduces cognitive load by grouping related functions,
minimizes tab switching during common tasks, and provides a clearer
mental model of the platform's workflow for new users.
2025-03-03 06:45:36 -05:00
Hexaa
49d4cea06f feat(zipline): update zipline version 2025-03-03 12:17:42 +07:00
72 changed files with 18427 additions and 1041 deletions

View File

@@ -1,6 +1,6 @@
name: Bug Report name: Bug Report
description: Create a bug report description: Create a bug report
labels: ["bug"] labels: ["needs-triage🔍"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@@ -62,6 +62,7 @@ body:
- "Docker" - "Docker"
- "Remote server" - "Remote server"
- "Local Development" - "Local Development"
- "Cloud Version"
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@@ -47,7 +47,7 @@ const baseAdmin: User = {
letsEncryptEmail: null, letsEncryptEmail: null,
sshPrivateKey: null, sshPrivateKey: null,
enableDockerCleanup: false, enableDockerCleanup: false,
enableLogRotation: false, logCleanupCron: null,
serversQuantity: 0, serversQuantity: 0,
stripeCustomerId: "", stripeCustomerId: "",
stripeSubscriptionId: "", stripeSubscriptionId: "",

View File

@@ -132,8 +132,8 @@ export const ShowEnvironment = ({ id, type }: Props) => {
control={form.control} control={form.control}
name="environment" name="environment"
render={({ field }) => ( render={({ field }) => (
<FormItem className="w-full"> <FormItem>
<FormControl> <FormControl className="">
<CodeEditor <CodeEditor
style={ style={
{ {
@@ -142,14 +142,14 @@ export const ShowEnvironment = ({ id, type }: Props) => {
} }
language="properties" language="properties"
disabled={isEnvVisible} disabled={isEnvVisible}
className="font-mono"
wrapperClassName="compose-file-editor"
placeholder={`NODE_ENV=production placeholder={`NODE_ENV=production
PORT=3000 PORT=3000
`} `}
className="h-96 font-mono"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -11,6 +11,7 @@ import { z } from "zod";
const addEnvironmentSchema = z.object({ const addEnvironmentSchema = z.object({
env: z.string(), env: z.string(),
buildArgs: z.string(), buildArgs: z.string(),
buildSecrets: z.record(z.string(), z.string()),
}); });
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>; type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -36,6 +37,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
defaultValues: { defaultValues: {
env: data?.env || "", env: data?.env || "",
buildArgs: data?.buildArgs || "", buildArgs: data?.buildArgs || "",
buildSecrets: data?.buildSecrets || {},
}, },
resolver: zodResolver(addEnvironmentSchema), resolver: zodResolver(addEnvironmentSchema),
}); });
@@ -44,6 +46,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
mutateAsync({ mutateAsync({
env: data.env, env: data.env,
buildArgs: data.buildArgs, buildArgs: data.buildArgs,
buildSecrets: data.buildSecrets,
applicationId, applicationId,
}) })
.then(async () => { .then(async () => {
@@ -69,25 +72,63 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")} placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
/> />
{data?.buildType === "dockerfile" && ( {data?.buildType === "dockerfile" && (
<Secrets <>
name="buildArgs" <Secrets
title="Build-time Variables" name="buildArgs"
description={ title="Build-time Variables"
<span> description={
Available only at build-time. See documentation&nbsp; <span>
<a Available only at build-time. See documentation&nbsp;
className="text-primary" <a
href="https://docs.docker.com/build/guide/build-args/" className="text-primary"
target="_blank" href="https://docs.docker.com/build/guide/build-args/"
rel="noopener noreferrer" target="_blank"
> rel="noopener noreferrer"
here >
</a> here
. </a>
</span> .
} </span>
placeholder="NPM_TOKEN=xyz" }
/> placeholder="NPM_TOKEN=xyz"
/>
<Secrets
name="buildSecrets"
title="Build Secrets"
description={
<span>
Secrets available only during build-time and not in the
final image. See documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/building/secrets/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</span>
}
placeholder="API_TOKEN=xyz"
transformValue={(value) => {
// Convert the string format to object
const lines = value.split("\n").filter((line) => line.trim());
return Object.fromEntries(
lines.map((line) => {
const [key, ...valueParts] = line.split("=");
return [key.trim(), valueParts.join("=").trim()];
}),
);
}}
formatValue={(value) => {
// Convert the object back to string format
return Object.entries(value as Record<string, string>)
.map(([key, val]) => `${key}=${val}`)
.join("\n");
}}
/>
</>
)} )}
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit"> <Button isLoading={isLoading} className="w-fit" type="submit">

View File

@@ -4,8 +4,22 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, Hammer, RefreshCcw, Terminal } from "lucide-react"; import {
Ban,
CheckCircle2,
Hammer,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { toast } from "sonner"; import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -41,128 +55,188 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<DialogAction <TooltipProvider delayDuration={0}>
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying application");
});
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Application reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading application");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Start Application" title="Deploy Application"
description="Are you sure you want to start this application?" description="Are you sure you want to deploy this application?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ await deploy({
applicationId: applicationId, applicationId: applicationId,
}) })
.then(() => { .then(() => {
toast.success("Application started successfully"); toast.success("Application deployed successfully");
refetch(); refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
);
}) })
.catch(() => { .catch(() => {
toast.error("Error starting application"); toast.error("Error deploying application");
}); });
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
Start variant="default"
<CheckCircle2 className="size-4" /> isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : (
<DialogAction <DialogAction
title="Stop Application" title="Reload Application"
description="Are you sure you want to stop this application?" description="Are you sure you want to reload this application?"
type="default"
onClick={async () => { onClick={async () => {
await stop({ await reload({
applicationId: applicationId, applicationId: applicationId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("Application stopped successfully"); toast.success("Application reloaded successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error stopping application"); toast.error("Error reloading application");
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button variant="secondary" isLoading={isReloading}>
Stop Reload
<Ban className="size-4" /> <RefreshCcw className="size-4" />
</Button> </Button>
</DialogAction> </DialogAction>
)} <DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Rebuild
<Hammer className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Only rebuilds the application without downloading new
code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Application"
description="Are you sure you want to start this application?"
type="default"
onClick={async () => {
await start({
applicationId: applicationId,
})
.then(() => {
toast.success("Application started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting application");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the application (requires a previous successful
build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Application"
description="Are you sure you want to stop this application?"
onClick={async () => {
await stop({
applicationId: applicationId,
})
.then(() => {
toast.success("Application stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping application");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running application</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -1,8 +1,15 @@
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, Hammer, Terminal } from "lucide-react"; import { Ban, CheckCircle2, Hammer, HelpCircle, Terminal } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { toast } from "sonner"; import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -27,103 +34,159 @@ export const ComposeActions = ({ composeId }: Props) => {
api.compose.stop.useMutation(); api.compose.stop.useMutation();
return ( return (
<div className="flex flex-row gap-4 w-full flex-wrap "> <div className="flex flex-row gap-4 w-full flex-wrap ">
<DialogAction <TooltipProvider delayDuration={0}>
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button variant="default" isLoading={data?.composeStatus === "running"}>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Rebuild Compose"
description="Are you sure you want to rebuild this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding compose");
});
}}
>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</DialogAction>
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<DialogAction <DialogAction
title="Start Compose" title="Deploy Compose"
description="Are you sure you want to start this compose?" description="Are you sure you want to deploy this compose?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ await deploy({
composeId: composeId, composeId: composeId,
}) })
.then(() => { .then(() => {
toast.success("Compose started successfully"); toast.success("Compose deployed successfully");
refetch(); refetch();
router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
);
}) })
.catch(() => { .catch(() => {
toast.error("Error starting compose"); toast.error("Error deploying compose");
}); });
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
Start variant="default"
<CheckCircle2 className="size-4" /> isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads the source code and performs a complete build</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : (
<DialogAction <DialogAction
title="Stop Compose" title="Rebuild Compose"
description="Are you sure you want to stop this compose?" description="Are you sure you want to rebuild this compose?"
type="default"
onClick={async () => { onClick={async () => {
await stop({ await redeploy({
composeId: composeId, composeId: composeId,
}) })
.then(() => { .then(() => {
toast.success("Compose stopped successfully"); toast.success("Compose rebuilt successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error stopping compose"); toast.error("Error rebuilding compose");
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button
Stop variant="secondary"
<Ban className="size-4" /> isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5"
>
Rebuild
<Hammer className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Only rebuilds the compose without downloading new code</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} {data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<DialogAction
title="Start Compose"
description="Are you sure you want to start this compose?"
type="default"
onClick={async () => {
await start({
composeId: composeId,
})
.then(() => {
toast.success("Compose started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting compose");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the compose (requires a previous successful build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Compose"
description="Are you sure you want to stop this compose?"
onClick={async () => {
await stop({
composeId: composeId,
})
.then(() => {
toast.success("Compose stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping compose");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running compose</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -2,8 +2,21 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs"; import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react"; import {
Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
@@ -65,92 +78,150 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<DialogAction <TooltipProvider delayDuration={0}>
title="Deploy Mariadb"
description="Are you sure you want to deploy this mariadb?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Mariadb"
description="Are you sure you want to reload this mariadb?"
type="default"
onClick={async () => {
await reload({
mariadbId: mariadbId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mariadb reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mariadb");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Start Mariadb" title="Deploy Mariadb"
description="Are you sure you want to start this mariadb?" description="Are you sure you want to deploy this mariadb?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ setIsDeploying(true);
mariadbId: mariadbId, await new Promise((resolve) => setTimeout(resolve, 1000));
}) refetch();
.then(() => {
toast.success("Mariadb started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mariadb");
});
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
Start variant="default"
<CheckCircle2 className="size-4" /> isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : (
<DialogAction <DialogAction
title="Stop Mariadb" title="Reload Mariadb"
description="Are you sure you want to stop this mariadb?" description="Are you sure you want to reload this mariadb?"
type="default"
onClick={async () => { onClick={async () => {
await stop({ await reload({
mariadbId: mariadbId, mariadbId: mariadbId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("Mariadb stopped successfully"); toast.success("Mariadb reloaded successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error stopping Mariadb"); toast.error("Error reloading Mariadb");
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button
Stop variant="secondary"
<Ban className="size-4" /> isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload
<RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MariaDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} {data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Mariadb"
description="Are you sure you want to start this mariadb?"
type="default"
onClick={async () => {
await start({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mariadb");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MariaDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mariadb"
description="Are you sure you want to stop this mariadb?"
onClick={async () => {
await stop({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mariadb");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -2,8 +2,21 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs"; import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react"; import {
Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
@@ -64,93 +77,150 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<DialogAction <TooltipProvider delayDuration={0}>
title="Deploy Mongo"
description="Are you sure you want to deploy this mongo?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Mongo"
description="Are you sure you want to reload this mongo?"
type="default"
onClick={async () => {
await reload({
mongoId: mongoId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mongo reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mongo");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Start Mongo" title="Deploy Mongo"
description="Are you sure you want to start this mongo?" description="Are you sure you want to deploy this mongo?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Mongo"
description="Are you sure you want to reload this mongo?"
type="default"
onClick={async () => {
await reload({
mongoId: mongoId, mongoId: mongoId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("Mongo started successfully"); toast.success("Mongo reloaded successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error starting Mongo"); toast.error("Error reloading Mongo");
}); });
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
Start variant="secondary"
<CheckCircle2 className="size-4" /> isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload
<RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MongoDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : ( {data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Stop Mongo" title="Start Mongo"
description="Are you sure you want to stop this mongo?" description="Are you sure you want to start this mongo?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await stop({ await start({
mongoId: mongoId, mongoId: mongoId,
})
.then(() => {
toast.success("Mongo stopped successfully");
refetch();
}) })
.catch(() => { .then(() => {
toast.error("Error stopping Mongo"); toast.success("Mongo started successfully");
}); refetch();
}} })
> .catch(() => {
<Button variant="destructive" isLoading={isStopping}> toast.error("Error starting Mongo");
Stop });
<Ban className="size-4" /> }}
</Button> >
</DialogAction> <Button
)} variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MongoDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mongo"
description="Are you sure you want to stop this mongo?"
onClick={async () => {
await stop({
mongoId: mongoId,
})
.then(() => {
toast.success("Mongo stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mongo");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -200,7 +200,7 @@ export const ContainerFreeMonitoring = ({
}, [appName]); }, [appName]);
return ( return (
<div className="rounded-xl bg-background shadow-md flex flex-col gap-4"> <div className="rounded-xl bg-background flex flex-col gap-4">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Monitoring</h1> <h1 className="text-2xl font-semibold tracking-tight">Monitoring</h1>

View File

@@ -2,8 +2,21 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs"; import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react"; import {
Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
@@ -62,93 +75,150 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<DialogAction <TooltipProvider delayDuration={0}>
title="Deploy Mysql"
description="Are you sure you want to deploy this mysql?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Mysql"
description="Are you sure you want to reload this mysql?"
type="default"
onClick={async () => {
await reload({
mysqlId: mysqlId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mysql reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mysql");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Start Mysql" title="Deploy Mysql"
description="Are you sure you want to start this mysql?" description="Are you sure you want to deploy this mysql?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ setIsDeploying(true);
mysqlId: mysqlId, await new Promise((resolve) => setTimeout(resolve, 1000));
}) refetch();
.then(() => {
toast.success("Mysql started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mysql");
});
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
Start variant="default"
<CheckCircle2 className="size-4" /> isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : (
<DialogAction <DialogAction
title="Stop Mysql" title="Reload Mysql"
description="Are you sure you want to stop this mysql?" description="Are you sure you want to reload this mysql?"
type="default"
onClick={async () => { onClick={async () => {
await stop({ await reload({
mysqlId: mysqlId, mysqlId: mysqlId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("Mysql stopped successfully"); toast.success("Mysql reloaded successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error stopping Mysql"); toast.error("Error reloading Mysql");
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button
Stop variant="secondary"
<Ban className="size-4" /> isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload
<RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MySQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} {data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Mysql"
description="Are you sure you want to start this mysql?"
type="default"
onClick={async () => {
await start({
mysqlId: mysqlId,
})
.then(() => {
toast.success("Mysql started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mysql");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MySQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mysql"
description="Are you sure you want to stop this mysql?"
onClick={async () => {
await stop({
mysqlId: mysqlId,
})
.then(() => {
toast.success("Mysql stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mysql");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -2,12 +2,26 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs"; import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react"; import {
Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props { interface Props {
postgresId: string; postgresId: string;
} }
@@ -57,122 +71,179 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
); );
return ( return (
<div className="flex w-full flex-col gap-5 "> <>
<Card className="bg-background"> <div className="flex w-full flex-col gap-5 ">
<CardHeader className="pb-4"> <Card className="bg-background">
<CardTitle className="text-xl">General</CardTitle> <CardHeader>
</CardHeader> <CardTitle className="text-xl">Deploy Settings</CardTitle>
<CardContent className="flex gap-4"> </CardHeader>
<DialogAction <CardContent className="flex flex-row gap-4 flex-wrap">
title="Deploy Postgres" <TooltipProvider delayDuration={0}>
description="Are you sure you want to deploy this postgres?" <DialogAction
type="default" title="Deploy Postgres"
onClick={async () => { description="Are you sure you want to deploy this postgres?"
setIsDeploying(true); type="default"
onClick={async () => {
await new Promise((resolve) => setTimeout(resolve, 1000)); setIsDeploying(true);
refetch(); await new Promise((resolve) => setTimeout(resolve, 1000));
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Postgres"
description="Are you sure you want to reload this postgres?"
type="default"
onClick={async () => {
await reload({
postgresId: postgresId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Postgres reloaded successfully");
refetch(); refetch();
}) }}
.catch(() => { >
toast.error("Error reloading Postgres"); <Button
}); variant="default"
}} isLoading={data?.applicationStatus === "running"}
> className="flex items-center gap-1.5"
<Button variant="secondary" isLoading={isReloading}> >
Reload Deploy
<RefreshCcw className="size-4" /> <Tooltip>
</Button> <TooltipTrigger asChild>
</DialogAction> <HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
{data?.applicationStatus === "idle" ? ( </TooltipTrigger>
<DialogAction <TooltipPrimitive.Portal>
title="Start Postgres" <TooltipContent sideOffset={5} className="z-[60]">
description="Are you sure you want to start this postgres?" <p>Downloads and sets up the PostgreSQL database</p>
type="default" </TooltipContent>
onClick={async () => { </TooltipPrimitive.Portal>
await start({ </Tooltip>
postgresId: postgresId, </Button>
}) </DialogAction>
.then(() => { <DialogAction
toast.success("Postgres started successfully"); title="Reload Postgres"
refetch(); description="Are you sure you want to reload this postgres?"
type="default"
onClick={async () => {
await reload({
postgresId: postgresId,
appName: data?.appName || "",
}) })
.catch(() => { .then(() => {
toast.error("Error starting Postgres"); toast.success("Postgres reloaded successfully");
}); refetch();
}} })
.catch(() => {
toast.error("Error reloading Postgres");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload
<RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the PostgreSQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Postgres"
description="Are you sure you want to start this postgres?"
type="default"
onClick={async () => {
await start({
postgresId: postgresId,
})
.then(() => {
toast.success("Postgres started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Postgres");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the PostgreSQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Postgres"
description="Are you sure you want to stop this postgres?"
onClick={async () => {
await stop({
postgresId: postgresId,
})
.then(() => {
toast.success("Postgres stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Postgres");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running PostgreSQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
> >
<Button variant="secondary" isLoading={isStarting}> <Button variant="outline">
Start <Terminal />
<CheckCircle2 className="size-4" /> Open Terminal
</Button> </Button>
</DialogAction> </DockerTerminalModal>
) : ( </CardContent>
<DialogAction </Card>
title="Stop Postgres" <DrawerLogs
description="Are you sure you want to stop this postgres?" isOpen={isDrawerOpen}
onClick={async () => { onClose={() => {
await stop({ setIsDrawerOpen(false);
postgresId: postgresId, setFilteredLogs([]);
}) setIsDeploying(false);
.then(() => { refetch();
toast.success("Postgres stopped successfully"); }}
refetch(); filteredLogs={filteredLogs}
}) />
.catch(() => { </div>
toast.error("Error stopping Postgres"); </>
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</Button>
</DialogAction>
)}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
</CardContent>
</Card>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
); );
}; };

View File

@@ -383,9 +383,8 @@ export const AddTemplate = ({ projectId }: Props) => {
side="top" side="top"
> >
<span> <span>
If ot server is selected, the application If no server is selected, the application will be
will be deployed on the server where the deployed on the server where the user is logged in.
user is logged in.
</span> </span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>

View File

@@ -2,12 +2,26 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs"; import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react"; import {
Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props { interface Props {
redisId: string; redisId: string;
} }
@@ -63,94 +77,150 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<DialogAction <TooltipProvider delayDuration={0}>
title="Deploy Redis"
description="Are you sure you want to deploy this redis?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Redis"
description="Are you sure you want to reload this redis?"
type="default"
onClick={async () => {
await reload({
redisId: redisId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Redis reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Redis");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
{/* <ResetRedis redisId={redisId} appName={data?.appName || ""} /> */}
{data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
title="Start Redis" title="Deploy Redis"
description="Are you sure you want to start this redis?" description="Are you sure you want to deploy this redis?"
type="default" type="default"
onClick={async () => { onClick={async () => {
await start({ setIsDeploying(true);
redisId: redisId, await new Promise((resolve) => setTimeout(resolve, 1000));
}) refetch();
.then(() => {
toast.success("Redis started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Redis");
});
}} }}
> >
<Button variant="secondary" isLoading={isStarting}> <Button
Start variant="default"
<CheckCircle2 className="size-4" /> isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the Redis database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
) : (
<DialogAction <DialogAction
title="Stop Redis" title="Reload Redis"
description="Are you sure you want to stop this redis?" description="Are you sure you want to reload this redis?"
type="default"
onClick={async () => { onClick={async () => {
await stop({ await reload({
redisId: redisId, redisId: redisId,
appName: data?.appName || "",
}) })
.then(() => { .then(() => {
toast.success("Redis stopped successfully"); toast.success("Redis reloaded successfully");
refetch(); refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error stopping Redis"); toast.error("Error reloading Redis");
}); });
}} }}
> >
<Button variant="destructive" isLoading={isStopping}> <Button
Stop variant="secondary"
<Ban className="size-4" /> isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload
<RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the Redis service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button> </Button>
</DialogAction> </DialogAction>
)} {data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Redis"
description="Are you sure you want to start this redis?"
type="default"
onClick={async () => {
await start({
redisId: redisId,
})
.then(() => {
toast.success("Redis started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Redis");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the Redis database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Redis"
description="Are you sure you want to stop this redis?"
onClick={async () => {
await stop({
redisId: redisId,
})
.then(() => {
toast.success("Redis stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Redis");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running Redis database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal <DockerTerminalModal
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}

View File

@@ -1,10 +1,10 @@
import { api } from "@/utils/api";
import { import {
type ChartConfig, type ChartConfig,
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from "@/components/ui/chart"; } from "@/components/ui/chart";
import { api } from "@/utils/api";
import { import {
Area, Area,
AreaChart, AreaChart,
@@ -14,6 +14,13 @@ import {
YAxis, YAxis,
} from "recharts"; } from "recharts";
export interface RequestDistributionChartProps {
dateRange?: {
from: Date | undefined;
to: Date | undefined;
};
}
const chartConfig = { const chartConfig = {
views: { views: {
label: "Page Views", label: "Page Views",
@@ -24,10 +31,22 @@ const chartConfig = {
}, },
} satisfies ChartConfig; } satisfies ChartConfig;
export const RequestDistributionChart = () => { export const RequestDistributionChart = ({
const { data: stats } = api.settings.readStats.useQuery(undefined, { dateRange,
refetchInterval: 1333, }: RequestDistributionChartProps) => {
}); const { data: stats } = api.settings.readStats.useQuery(
{
dateRange: dateRange
? {
start: dateRange.from?.toISOString(),
end: dateRange.to?.toISOString(),
}
: undefined,
},
{
refetchInterval: 1333,
},
);
return ( return (
<ResponsiveContainer width="100%" height={200}> <ResponsiveContainer width="100%" height={200}>

View File

@@ -79,7 +79,15 @@ export const priorities = [
icon: Server, icon: Server,
}, },
]; ];
export const RequestsTable = () => {
export interface RequestsTableProps {
dateRange?: {
from: Date | undefined;
to: Date | undefined;
};
}
export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
const [statusFilter, setStatusFilter] = useState<string[]>([]); const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [selectedRow, setSelectedRow] = useState<LogEntry>(); const [selectedRow, setSelectedRow] = useState<LogEntry>();
@@ -98,6 +106,12 @@ export const RequestsTable = () => {
page: pagination, page: pagination,
search, search,
status: statusFilter, status: statusFilter,
dateRange: dateRange
? {
start: dateRange.from?.toISOString(),
end: dateRange.to?.toISOString(),
}
: undefined,
}, },
{ {
refetchInterval: 1333, refetchInterval: 1333,

View File

@@ -1,6 +1,7 @@
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { import {
Card, Card,
CardContent, CardContent,
@@ -8,9 +9,29 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { type RouterOutputs, api } from "@/utils/api"; import { type RouterOutputs, api } from "@/utils/api";
import { ArrowDownUp } from "lucide-react"; import { format } from "date-fns";
import {
ArrowDownUp,
AlertCircle,
InfoIcon,
Calendar as CalendarIcon,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { RequestDistributionChart } from "./request-distribution-chart"; import { RequestDistributionChart } from "./request-distribution-chart";
import { RequestsTable } from "./requests-table"; import { RequestsTable } from "./requests-table";
@@ -20,17 +41,30 @@ export type LogEntry = NonNullable<
>[0]; >[0];
export const ShowRequests = () => { export const ShowRequests = () => {
const { data: isLogRotateActive, refetch: refetchLogRotate } =
api.settings.getLogRotateStatus.useQuery();
const { mutateAsync: toggleLogRotate } =
api.settings.toggleLogRotate.useMutation();
const { data: isActive, refetch } = const { data: isActive, refetch } =
api.settings.haveActivateRequests.useQuery(); api.settings.haveActivateRequests.useQuery();
const { mutateAsync: toggleRequests } = const { mutateAsync: toggleRequests } =
api.settings.toggleRequests.useMutation(); api.settings.toggleRequests.useMutation();
const { data: logCleanupStatus } =
api.settings.getLogCleanupStatus.useQuery();
const { mutateAsync: updateLogCleanup } =
api.settings.updateLogCleanup.useMutation();
const [cronExpression, setCronExpression] = useState<string | null>(null);
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
}>({
from: undefined,
to: undefined,
});
useEffect(() => {
if (logCleanupStatus) {
setCronExpression(logCleanupStatus.cronExpression || "0 0 * * *");
}
}, [logCleanupStatus]);
return ( return (
<> <>
<div className="w-full"> <div className="w-full">
@@ -57,7 +91,60 @@ export const ShowRequests = () => {
</AlertBlock> </AlertBlock>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 py-8 border-t"> <CardContent className="space-y-2 py-8 border-t">
<div className="flex w-full gap-4 justify-end"> <div className="flex w-full gap-4 justify-end items-center">
<div className="flex-1 flex items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="cron" className="min-w-32">
Log Cleanup Schedule
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p className="max-w-80">
At the scheduled time, the cleanup job will keep
only the last 1000 entries in the access log file
and signal Traefik to reopen its log files. The
default schedule is daily at midnight (0 0 * * *).
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex-1 flex gap-4">
<Input
id="cron"
placeholder="0 0 * * *"
value={cronExpression || ""}
onChange={(e) => setCronExpression(e.target.value)}
className="max-w-60"
required
/>
<Button
variant="outline"
onClick={async () => {
if (!cronExpression?.trim()) {
toast.error("Please enter a valid cron expression");
return;
}
try {
await updateLogCleanup({
cronExpression: cronExpression,
});
toast.success("Log cleanup schedule updated");
} catch (error) {
toast.error(
`Failed to update log cleanup schedule: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}}
>
Update Schedule
</Button>
</div>
</div>
<DialogAction <DialogAction
title={isActive ? "Deactivate Requests" : "Activate Requests"} title={isActive ? "Deactivate Requests" : "Activate Requests"}
description="You will also need to restart Traefik to apply the changes" description="You will also need to restart Traefik to apply the changes"
@@ -77,53 +164,81 @@ export const ShowRequests = () => {
> >
<Button>{isActive ? "Deactivate" : "Activate"}</Button> <Button>{isActive ? "Deactivate" : "Activate"}</Button>
</DialogAction> </DialogAction>
<DialogAction
title={
isLogRotateActive
? "Activate Log Rotate"
: "Deactivate Log Rotate"
}
description={
isLogRotateActive
? "This will make the logs rotate on interval 1 day and maximum size of 100 MB and maximum 6 logs"
: "The log rotation will be disabled"
}
onClick={() => {
toggleLogRotate({
enable: !isLogRotateActive,
})
.then(() => {
toast.success(
`Log rotate ${isLogRotateActive ? "activated" : "deactivated"}`,
);
refetchLogRotate();
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Button variant="secondary">
{isLogRotateActive
? "Activate Log Rotate"
: "Deactivate Log Rotate"}
</Button>
</DialogAction>
</div> </div>
<div> {isActive ? (
{isActive ? ( <>
<RequestDistributionChart /> <div className="flex justify-end mb-4 gap-2">
) : ( {(dateRange.from || dateRange.to) && (
<div className="flex items-center justify-center min-h-[25vh]"> <Button
<span className="text-muted-foreground py-6"> variant="outline"
You need to activate requests onClick={() =>
</span> setDateRange({ from: undefined, to: undefined })
}
className="px-3"
>
Clear dates
</Button>
)}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-[300px] justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateRange.from ? (
dateRange.to ? (
<>
{format(dateRange.from, "LLL dd, y")} -{" "}
{format(dateRange.to, "LLL dd, y")}
</>
) : (
format(dateRange.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange.from}
selected={{
from: dateRange.from,
to: dateRange.to,
}}
onSelect={(range) => {
setDateRange({
from: range?.from,
to: range?.to,
});
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div> </div>
)} <RequestDistributionChart dateRange={dateRange} />
{isActive && <RequestsTable />} <RequestsTable dateRange={dateRange} />
</div> </>
) : (
<div className="flex flex-col items-center justify-center py-12 gap-4 text-muted-foreground">
<AlertCircle className="size-12 text-muted-foreground/50" />
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">
Requests are not activated
</h3>
<p className="text-sm max-w-md">
Activate requests to see incoming traffic statistics and
monitor your application's usage. After activation, you'll
need to reload Traefik for the changes to take effect.
</p>
</div>
</div>
)}
</CardContent> </CardContent>
</div> </div>
</Card> </Card>

View File

@@ -39,12 +39,12 @@ import { S3_PROVIDERS } from "./constants";
const addDestination = z.object({ const addDestination = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
provider: z.string().optional(), provider: z.string().min(1, "Provider is required"),
accessKeyId: z.string(), accessKeyId: z.string().min(1, "Access Key Id is required"),
secretAccessKey: z.string(), secretAccessKey: z.string().min(1, "Secret Access Key is required"),
bucket: z.string(), bucket: z.string().min(1, "Bucket is required"),
region: z.string(), region: z.string(),
endpoint: z.string(), endpoint: z.string().min(1, "Endpoint is required"),
serverId: z.string().optional(), serverId: z.string().optional(),
}); });
@@ -129,6 +129,63 @@ export const HandleDestinations = ({ destinationId }: Props) => {
); );
}); });
}; };
const handleTestConnection = async (serverId?: string) => {
const result = await form.trigger([
"provider",
"accessKeyId",
"secretAccessKey",
"bucket",
"endpoint",
]);
if (!result) {
const errors = form.formState.errors;
const errorFields = Object.entries(errors)
.map(([field, error]) => `${field}: ${error?.message}`)
.filter(Boolean)
.join("\n");
toast.error("Please fill all required fields", {
description: errorFields,
});
return;
}
if (isCloud && !serverId) {
toast.error("Please select a server");
return;
}
const provider = form.getValues("provider");
const accessKey = form.getValues("accessKeyId");
const secretKey = form.getValues("secretAccessKey");
const bucket = form.getValues("bucket");
const endpoint = form.getValues("endpoint");
const region = form.getValues("region");
const connectionString = `:s3,provider=${provider},access_key_id=${accessKey},secret_access_key=${secretKey},endpoint=${endpoint}${region ? `,region=${region}` : ""}:${bucket}`;
await testConnection({
provider,
accessKey,
bucket,
endpoint,
name: "Test",
region,
secretAccessKey: secretKey,
serverId,
})
.then(() => {
toast.success("Connection Success");
})
.catch((e) => {
toast.error("Error connecting to provider", {
description: `${e.message}\n\nTry manually: rclone ls ${connectionString}`,
});
});
};
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="" asChild> <DialogTrigger className="" asChild>
@@ -349,26 +406,9 @@ export const HandleDestinations = ({ destinationId }: Props) => {
<Button <Button
type="button" type="button"
variant={"secondary"} variant={"secondary"}
isLoading={isLoading} isLoading={isLoadingConnection}
onClick={async () => { onClick={async () => {
await testConnection({ await handleTestConnection(form.getValues("serverId"));
provider: form.getValues("provider") || "",
accessKey: form.getValues("accessKeyId"),
bucket: form.getValues("bucket"),
endpoint: form.getValues("endpoint"),
name: "Test",
region: form.getValues("region"),
secretAccessKey: form.getValues("secretAccessKey"),
serverId: form.getValues("serverId"),
})
.then(async () => {
toast.success("Connection Success");
})
.catch((e) => {
toast.error("Error connecting the provider", {
description: e.message,
});
});
}} }}
> >
Test Connection Test Connection
@@ -380,21 +420,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
type="button" type="button"
variant="secondary" variant="secondary"
onClick={async () => { onClick={async () => {
await testConnection({ await handleTestConnection();
provider: form.getValues("provider") || "",
accessKey: form.getValues("accessKeyId"),
bucket: form.getValues("bucket"),
endpoint: form.getValues("endpoint"),
name: "Test",
region: form.getValues("region"),
secretAccessKey: form.getValues("secretAccessKey"),
})
.then(async () => {
toast.success("Connection Success");
})
.catch(() => {
toast.error("Error connecting the provider");
});
}} }}
> >
Test connection Test connection

View File

@@ -56,9 +56,17 @@ export const ShowDestinations = () => {
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm"> <div className="flex flex-col gap-1">
{index + 1}. {destination.name} <span className="text-sm">
</span> {index + 1}. {destination.name}
</span>
<span className="text-xs text-muted-foreground">
Created at:{" "}
{new Date(
destination.createdAt,
).toLocaleDateString()}
</span>
</div>
<div className="flex flex-row gap-1"> <div className="flex flex-row gap-1">
<HandleDestinations <HandleDestinations
destinationId={destination.destinationId} destinationId={destination.destinationId}

View File

@@ -45,21 +45,12 @@ const Schema = z.object({
type Schema = z.infer<typeof Schema>; type Schema = z.infer<typeof Schema>;
interface Model {
id: string;
object: string;
created: number;
owned_by: string;
}
interface Props { interface Props {
aiId?: string; aiId?: string;
} }
export const HandleAi = ({ aiId }: Props) => { export const HandleAi = ({ aiId }: Props) => {
const [models, setModels] = useState<Model[]>([]);
const utils = api.useUtils(); const utils = api.useUtils();
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { data, refetch } = api.ai.one.useQuery( const { data, refetch } = api.ai.one.useQuery(
@@ -73,6 +64,7 @@ export const HandleAi = ({ aiId }: Props) => {
const { mutateAsync, isLoading } = aiId const { mutateAsync, isLoading } = aiId
? api.ai.update.useMutation() ? api.ai.update.useMutation()
: api.ai.create.useMutation(); : api.ai.create.useMutation();
const form = useForm<Schema>({ const form = useForm<Schema>({
resolver: zodResolver(Schema), resolver: zodResolver(Schema),
defaultValues: { defaultValues: {
@@ -94,50 +86,33 @@ export const HandleAi = ({ aiId }: Props) => {
}); });
}, [aiId, form, data]); }, [aiId, form, data]);
const fetchModels = async (apiUrl: string, apiKey: string) => { const apiUrl = form.watch("apiUrl");
setIsLoadingModels(true); const apiKey = form.watch("apiKey");
setError(null);
try {
const response = await fetch(`${apiUrl}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
throw new Error("Failed to fetch models");
}
const res = await response.json();
setModels(res.data);
// Set default model to gpt-4 if present const { data: models, isLoading: isLoadingServerModels } =
const defaultModel = res.data.find( api.ai.getModels.useQuery(
(model: Model) => model.id === "gpt-4", {
); apiUrl: apiUrl ?? "",
if (defaultModel) { apiKey: apiKey ?? "",
form.setValue("model", defaultModel.id); },
return defaultModel.id; {
} enabled: !!apiUrl && !!apiKey,
} catch (error) { onError: (error) => {
setError("Failed to fetch models. Please check your API URL and Key."); setError(`Failed to fetch models: ${error.message}`);
setModels([]); },
} finally { },
setIsLoadingModels(false); );
}
};
useEffect(() => { useEffect(() => {
const apiUrl = form.watch("apiUrl"); const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey"); const apiKey = form.watch("apiKey");
if (apiUrl && apiKey) { if (apiUrl && apiKey) {
form.setValue("model", ""); form.setValue("model", "");
fetchModels(apiUrl, apiKey);
} }
}, [form.watch("apiUrl"), form.watch("apiKey")]); }, [form.watch("apiUrl"), form.watch("apiKey")]);
const onSubmit = async (data: Schema) => { const onSubmit = async (data: Schema) => {
try { try {
console.log("Form data:", data);
console.log("Current model value:", form.getValues("model"));
await mutateAsync({ await mutateAsync({
...data, ...data,
aiId: aiId || "", aiId: aiId || "",
@@ -148,8 +123,9 @@ export const HandleAi = ({ aiId }: Props) => {
refetch(); refetch();
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
console.error("Submit error:", error); toast.error("Failed to save AI settings", {
toast.error("Failed to save AI settings"); description: error instanceof Error ? error.message : "Unknown error",
});
} }
}; };
@@ -232,13 +208,13 @@ export const HandleAi = ({ aiId }: Props) => {
)} )}
/> />
{isLoadingModels && ( {isLoadingServerModels && (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Loading models... Loading models...
</span> </span>
)} )}
{!isLoadingModels && models.length > 0 && ( {!isLoadingServerModels && models && models.length > 0 && (
<FormField <FormField
control={form.control} control={form.control}
name="model" name="model"

View File

@@ -5,6 +5,12 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import type { IUpdateData } from "@dokploy/server/index"; import type { IUpdateData } from "@dokploy/server/index";
import { import {
@@ -24,9 +30,17 @@ import { UpdateWebServer } from "./update-webserver";
interface Props { interface Props {
updateData?: IUpdateData; updateData?: IUpdateData;
children?: React.ReactNode;
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
} }
export const UpdateServer = ({ updateData }: Props) => { export const UpdateServer = ({
updateData,
children,
isOpen: isOpenProp,
onOpenChange: onOpenChangeProp,
}: Props) => {
const [hasCheckedUpdate, setHasCheckedUpdate] = useState(!!updateData); const [hasCheckedUpdate, setHasCheckedUpdate] = useState(!!updateData);
const [isUpdateAvailable, setIsUpdateAvailable] = useState( const [isUpdateAvailable, setIsUpdateAvailable] = useState(
!!updateData?.updateAvailable, !!updateData?.updateAvailable,
@@ -35,10 +49,10 @@ export const UpdateServer = ({ updateData }: Props) => {
api.settings.getUpdateData.useMutation(); api.settings.getUpdateData.useMutation();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: releaseTag } = api.settings.getReleaseTag.useQuery(); const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
const [isOpen, setIsOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState( const [latestVersion, setLatestVersion] = useState(
updateData?.latestVersion ?? "", updateData?.latestVersion ?? "",
); );
const [isOpenInternal, setIsOpenInternal] = useState(false);
const handleCheckUpdates = async () => { const handleCheckUpdates = async () => {
try { try {
@@ -65,28 +79,52 @@ export const UpdateServer = ({ updateData }: Props) => {
} }
}; };
const isOpen = isOpenInternal || isOpenProp;
const onOpenChange = (open: boolean) => {
setIsOpenInternal(open);
onOpenChangeProp?.(open);
};
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button {children ? (
variant={updateData ? "outline" : "secondary"} children
className="gap-2" ) : (
> <TooltipProvider delayDuration={0}>
{updateData ? ( <Tooltip>
<> <TooltipTrigger asChild>
<span className="flex h-2 w-2"> <Button
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-emerald-400 opacity-75" /> variant={updateData ? "outline" : "secondary"}
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" /> size="sm"
</span> onClick={() => onOpenChange?.(true)}
Update available >
</> <Download className="h-4 w-4 flex-shrink-0" />
) : ( {updateData ? (
<> <span className="font-medium truncate group-data-[collapsible=icon]:hidden">
<Sparkles className="h-4 w-4" /> Update Available
Updates </span>
</> ) : (
)} <span className="font-medium truncate group-data-[collapsible=icon]:hidden">
</Button> Check for updates
</span>
)}
{updateData && (
<span className="absolute right-2 flex h-2 w-2 group-data-[collapsible=icon]:hidden">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
)}
</Button>
</TooltipTrigger>
{updateData && (
<TooltipContent side="right" sideOffset={10}>
<p>Update Available</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-lg p-6"> <DialogContent className="max-w-lg p-6">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
@@ -217,7 +255,7 @@ export const UpdateServer = ({ updateData }: Props) => {
<div className="space-y-4 flex items-center justify-end"> <div className="space-y-4 flex items-center justify-end">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)}> <Button variant="outline" onClick={() => onOpenChange?.(false)}>
Cancel Cancel
</Button> </Button>
{isUpdateAvailable ? ( {isUpdateAvailable ? (

View File

@@ -37,8 +37,6 @@ import {
BreadcrumbItem, BreadcrumbItem,
BreadcrumbLink, BreadcrumbLink,
BreadcrumbList, BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { import {
Collapsible, Collapsible,
@@ -1017,18 +1015,16 @@ export default function Page({ children }: Props) {
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
{!isCloud && auth?.role === "owner" && (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<UpdateServerButton />
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<SidebarMenu> <SidebarMenu className="flex flex-col gap-2">
{!isCloud && auth?.role === "owner" && (
<SidebarMenuItem>
<UpdateServerButton />
</SidebarMenuItem>
)}
<SidebarMenuItem> <SidebarMenuItem>
<UserNav /> <UserNav />
</SidebarMenuItem> </SidebarMenuItem>
@@ -1055,10 +1051,6 @@ export default function Page({ children }: Props) {
</Link> </Link>
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator className="block" />
<BreadcrumbItem>
<BreadcrumbPage>{activeItem?.title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
</div> </div>

View File

@@ -3,7 +3,14 @@ import type { IUpdateData } from "@dokploy/server/index";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import UpdateServer from "../dashboard/settings/web-server/update-server"; import UpdateServer from "../dashboard/settings/web-server/update-server";
import { Button } from "../ui/button";
import { Download } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7; const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
export const UpdateServerButton = () => { export const UpdateServerButton = () => {
@@ -15,6 +22,7 @@ export const UpdateServerButton = () => {
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync: getUpdateData } = const { mutateAsync: getUpdateData } =
api.settings.getUpdateData.useMutation(); api.settings.getUpdateData.useMutation();
const [isOpen, setIsOpen] = useState(false);
const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null); const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null);
@@ -69,11 +77,47 @@ export const UpdateServerButton = () => {
}; };
}, []); }, []);
return ( return updateData.updateAvailable ? (
updateData.updateAvailable && ( <div className="border-t pt-4">
<div> <UpdateServer
<UpdateServer updateData={updateData} /> updateData={updateData}
</div> isOpen={isOpen}
) onOpenChange={setIsOpen}
); >
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={updateData ? "outline" : "secondary"}
className="w-full"
onClick={() => setIsOpen(true)}
>
<Download className="h-4 w-4 flex-shrink-0" />
{updateData ? (
<span className="font-medium truncate group-data-[collapsible=icon]:hidden">
Update Available
</span>
) : (
<span className="font-medium truncate group-data-[collapsible=icon]:hidden">
Check for updates
</span>
)}
{updateData && (
<span className="absolute right-2 flex h-2 w-2 group-data-[collapsible=icon]:hidden">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
)}
</Button>
</TooltipTrigger>
{updateData && (
<TooltipContent side="right" sideOffset={10}>
<p>Update Available</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</UpdateServer>
</div>
) : null;
}; };

View File

@@ -37,7 +37,7 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
)} )}
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbSeparator className="block" /> {_index + 1 < list.length && <BreadcrumbSeparator className="block" />}
</Fragment> </Fragment>
))} ))}
</BreadcrumbList> </BreadcrumbList>

View File

@@ -0,0 +1,68 @@
import type * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1 @@
ALTER TABLE "destination" ADD COLUMN "createdAt" timestamp DEFAULT now() NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "user_temp" ADD COLUMN "logCleanupCron" text;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "application" ADD COLUMN "buildSecrets" text;--> statement-breakpoint
ALTER TABLE "user_temp" DROP COLUMN "enableLogRotation";

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -491,6 +491,27 @@
"when": 1741152916611, "when": 1741152916611,
"tag": "0069_legal_bill_hollister", "tag": "0069_legal_bill_hollister",
"breakpoints": true "breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1741322697251,
"tag": "0070_useful_serpent_society",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1741460060541,
"tag": "0071_flaky_black_queen",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1741481694393,
"tag": "0072_milky_lyja",
"breakpoints": true
} }
] ]
} }

View File

@@ -65,6 +65,7 @@ import {
PlusIcon, PlusIcon,
Search, Search,
X, X,
Trash2,
} from "lucide-react"; } from "lucide-react";
import type { import type {
GetServerSidePropsContext, GetServerSidePropsContext,
@@ -72,9 +73,25 @@ import type {
} from "next"; } from "next";
import Head from "next/head"; import Head from "next/head";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { type ReactElement, useMemo, useState } from "react"; import { type ReactElement, useMemo, useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import superjson from "superjson"; import superjson from "superjson";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type Services = { export type Services = {
appName: string; appName: string;
@@ -203,10 +220,47 @@ const Project = (
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false); const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
const { projectId } = props; const { projectId } = props;
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("servicesSort") || "createdAt-desc";
}
return "createdAt-desc";
});
useEffect(() => {
localStorage.setItem("servicesSort", sortBy);
}, [sortBy]);
const sortServices = (services: Services[]) => {
const [field, direction] = sortBy.split("-");
return [...services].sort((a, b) => {
let comparison = 0;
switch (field) {
case "name":
comparison = a.name.localeCompare(b.name);
break;
case "type":
comparison = a.type.localeCompare(b.type);
break;
case "createdAt":
comparison =
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
break;
default:
comparison = 0;
}
return direction === "asc" ? comparison : -comparison;
});
};
const { data, isLoading, refetch } = api.project.one.useQuery({ projectId }); const { data, isLoading, refetch } = api.project.one.useQuery({ projectId });
const { data: allProjects } = api.project.all.useQuery();
const router = useRouter(); const router = useRouter();
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
const [selectedTargetProject, setSelectedTargetProject] =
useState<string>("");
const emptyServices = const emptyServices =
data?.mariadb?.length === 0 && data?.mariadb?.length === 0 &&
data?.mongo?.length === 0 && data?.mongo?.length === 0 &&
@@ -254,6 +308,38 @@ const Project = (
const composeActions = { const composeActions = {
start: api.compose.start.useMutation(), start: api.compose.start.useMutation(),
stop: api.compose.stop.useMutation(), stop: api.compose.stop.useMutation(),
move: api.compose.move.useMutation(),
delete: api.compose.delete.useMutation(),
};
const applicationActions = {
move: api.application.move.useMutation(),
delete: api.application.delete.useMutation(),
};
const postgresActions = {
move: api.postgres.move.useMutation(),
delete: api.postgres.remove.useMutation(),
};
const mysqlActions = {
move: api.mysql.move.useMutation(),
delete: api.mysql.remove.useMutation(),
};
const mariadbActions = {
move: api.mariadb.move.useMutation(),
delete: api.mariadb.remove.useMutation(),
};
const redisActions = {
move: api.redis.move.useMutation(),
delete: api.redis.remove.useMutation(),
};
const mongoActions = {
move: api.mongo.move.useMutation(),
delete: api.mongo.remove.useMutation(),
}; };
const handleBulkStart = async () => { const handleBulkStart = async () => {
@@ -296,9 +382,145 @@ const Project = (
setIsBulkActionLoading(false); setIsBulkActionLoading(false);
}; };
const handleBulkMove = async () => {
if (!selectedTargetProject) {
toast.error("Please select a target project");
return;
}
let success = 0;
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
const service = filteredServices.find((s) => s.id === serviceId);
if (!service) continue;
switch (service.type) {
case "application":
await applicationActions.move.mutateAsync({
applicationId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "compose":
await composeActions.move.mutateAsync({
composeId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "postgres":
await postgresActions.move.mutateAsync({
postgresId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "mysql":
await mysqlActions.move.mutateAsync({
mysqlId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "mariadb":
await mariadbActions.move.mutateAsync({
mariadbId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "redis":
await redisActions.move.mutateAsync({
redisId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "mongo":
await mongoActions.move.mutateAsync({
mongoId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
}
success++;
} catch (error) {
toast.error(
`Error moving service ${serviceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
if (success > 0) {
toast.success(`${success} services moved successfully`);
refetch();
}
setSelectedServices([]);
setIsDropdownOpen(false);
setIsMoveDialogOpen(false);
setIsBulkActionLoading(false);
};
const handleBulkDelete = async () => {
let success = 0;
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
const service = filteredServices.find((s) => s.id === serviceId);
if (!service) continue;
switch (service.type) {
case "application":
await applicationActions.delete.mutateAsync({
applicationId: serviceId,
});
break;
case "compose":
await composeActions.delete.mutateAsync({
composeId: serviceId,
deleteVolumes: false,
});
break;
case "postgres":
await postgresActions.delete.mutateAsync({
postgresId: serviceId,
});
break;
case "mysql":
await mysqlActions.delete.mutateAsync({
mysqlId: serviceId,
});
break;
case "mariadb":
await mariadbActions.delete.mutateAsync({
mariadbId: serviceId,
});
break;
case "redis":
await redisActions.delete.mutateAsync({
redisId: serviceId,
});
break;
case "mongo":
await mongoActions.delete.mutateAsync({
mongoId: serviceId,
});
break;
}
success++;
} catch (error) {
toast.error(
`Error deleting service ${serviceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
if (success > 0) {
toast.success(`${success} services deleted successfully`);
refetch();
}
setSelectedServices([]);
setIsDropdownOpen(false);
setIsBulkActionLoading(false);
};
const filteredServices = useMemo(() => { const filteredServices = useMemo(() => {
if (!applications) return []; if (!applications) return [];
return applications.filter( const filtered = applications.filter(
(service) => (service) =>
(service.name.toLowerCase().includes(searchQuery.toLowerCase()) || (service.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
service.description service.description
@@ -306,7 +528,8 @@ const Project = (
.includes(searchQuery.toLowerCase())) && .includes(searchQuery.toLowerCase())) &&
(selectedTypes.length === 0 || selectedTypes.includes(service.type)), (selectedTypes.length === 0 || selectedTypes.includes(service.type)),
); );
}, [applications, searchQuery, selectedTypes]); return sortServices(filtered);
}, [applications, searchQuery, selectedTypes, sortBy]);
return ( return (
<div> <div>
@@ -380,7 +603,7 @@ const Project = (
</div> </div>
) : ( ) : (
<> <>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
@@ -445,11 +668,107 @@ const Project = (
Stop Stop
</Button> </Button>
</DialogAction> </DialogAction>
{(auth?.role === "owner" ||
auth?.canDeleteServices) && (
<DialogAction
title="Delete Services"
description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`}
type="destructive"
onClick={handleBulkDelete}
>
<Button
variant="ghost"
className="w-full justify-start text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</DialogAction>
)}
<Dialog
open={isMoveDialogOpen}
onOpenChange={setIsMoveDialogOpen}
>
<DialogTrigger asChild>
<Button
variant="ghost"
className="w-full justify-start"
>
<FolderInput className="mr-2 h-4 w-4" />
Move
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Services</DialogTitle>
<DialogDescription>
Select the target project to move{" "}
{selectedServices.length} services
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{allProjects?.filter(
(p) => p.projectId !== projectId,
).length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-4">
<FolderInput className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center">
No other projects available. Create a new
project first to move services.
</p>
</div>
) : (
<Select
value={selectedTargetProject}
onValueChange={setSelectedTargetProject}
>
<SelectTrigger>
<SelectValue placeholder="Select target project" />
</SelectTrigger>
<SelectContent>
{allProjects
?.filter(
(p) => p.projectId !== projectId,
)
.map((project) => (
<SelectItem
key={project.projectId}
value={project.projectId}
>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMoveDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleBulkMove}
isLoading={isBulkActionLoading}
disabled={
allProjects?.filter(
(p) => p.projectId !== projectId,
).length === 0
}
>
Move Services
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center"> <div className="flex flex-col gap-2 lg:flex-row lg:gap-4 lg:items-center">
<div className="w-full relative"> <div className="w-full relative">
<Input <Input
placeholder="Filter services..." placeholder="Filter services..."
@@ -459,6 +778,23 @@ const Project = (
/> />
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" /> <Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div> </div>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="lg:w-[280px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="createdAt-desc">
Newest first
</SelectItem>
<SelectItem value="createdAt-asc">
Oldest first
</SelectItem>
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
<SelectItem value="type-asc">Type (A-Z)</SelectItem>
<SelectItem value="type-desc">Type (Z-A)</SelectItem>
</SelectContent>
</Select>
<Popover <Popover
open={openCombobox} open={openCombobox}
onOpenChange={setOpenCombobox} onOpenChange={setOpenCombobox}

View File

@@ -228,15 +228,15 @@ const Service = (
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> <TabsTrigger value="environment">Environment</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && ( <TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="preview-deployments"> <TabsTrigger value="preview-deployments">
Preview Deployments Preview Deployments
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger> <TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
<TabsTrigger value="advanced">Advanced</TabsTrigger> <TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View File

@@ -224,12 +224,12 @@ const Service = (
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> <TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && ( {((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> <TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)} )}
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger> <TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View File

@@ -197,11 +197,11 @@ const Mariadb = (
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> <TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && ( {((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> <TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)} )}
<TabsTrigger value="backups">Backups</TabsTrigger> <TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger> <TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View File

@@ -198,11 +198,11 @@ const Mongo = (
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> <TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && ( {((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> <TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)} )}
<TabsTrigger value="backups">Backups</TabsTrigger> <TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger> <TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View File

@@ -200,13 +200,13 @@ const MySql = (
<TabsTrigger value="environment"> <TabsTrigger value="environment">
Environment Environment
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && ( {((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring"> <TabsTrigger value="monitoring">
Monitoring Monitoring
</TabsTrigger> </TabsTrigger>
)} )}
<TabsTrigger value="backups">Backups</TabsTrigger> <TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger> <TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View File

@@ -197,11 +197,11 @@ const Postgresql = (
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> <TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && ( {((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> <TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)} )}
<TabsTrigger value="backups">Backups</TabsTrigger> <TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger> <TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View File

@@ -197,10 +197,10 @@ const Redis = (
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger> <TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && ( {((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger> <TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)} )}
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger> <TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList> </TabsList>
</div> </div>

View File

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" width="163" height="32" fill="none" viewBox="0 0 163 32">
<g clip-path="url(#a)">
<rect width="32" height="32" fill="#FF6928" rx="16" />
<path fill="#fff" fill-rule="evenodd" d="M7.556 29.778C-3 29.778-11.556 23.609-11.556 16c0-7.61 8.557-13.778 19.112-13.778 11.17 0 19.11 5.905 19.11 13.778s-7.94 13.778-19.11 13.778Zm0-8c-10.17 0-17.334-2.587-17.334-5.778 0-3.191 7.163-5.778 17.334-5.778 10.17 0 17.333 2.128 17.333 5.778 0 3.65-7.163 5.778-17.333 5.778Z" clip-rule="evenodd" />
<g filter="url(#b)" opacity=".3">
<path fill="#FF6928" d="M1.893 11.25C4.192 6.285 5.753 3.667 10.495.177L7.9-5.684-5.308-2.17l-.438 15.1 7.639-1.678Z" />
</g>
<g filter="url(#c)" opacity=".3">
<path fill="#FF6928" d="M12.434 29.6c-4.607-2.955-6.988-4.86-9.798-10.033l-6.16 1.773 1.679 13.563 14.898 2.493-.62-7.796Z" />
</g>
</g>
<path fill="currentColor" d="M46.18 22.416c4.02 0 6.03-2.171 6.03-6.514v-.227c0-2.143-.493-3.745-1.48-4.807-.966-1.081-2.502-1.622-4.607-1.622h-1.82v13.17h1.877ZM39.694 5.662h6.656c3.47 0 6.144.892 8.022 2.674 1.763 1.764 2.645 4.19 2.645 7.282v.227c0 3.13-.891 5.585-2.674 7.367C52.485 25.071 49.811 26 46.322 26h-6.628V5.662Zm23.788 20.65c-1.441 0-2.608-.35-3.499-1.052-.986-.777-1.479-1.905-1.479-3.384 0-1.65.72-2.883 2.162-3.698 1.29-.72 3.148-1.081 5.575-1.081h1.678V16.5c0-.949-.18-1.64-.54-2.077-.342-.436-.968-.654-1.878-.654-1.46 0-2.304.701-2.531 2.105h-3.897c.114-1.669.806-2.958 2.076-3.869 1.157-.815 2.693-1.223 4.608-1.223 1.916 0 3.414.417 4.494 1.252 1.157.91 1.736 2.332 1.736 4.266V26h-4.011v-1.792c-1.005 1.403-2.503 2.105-4.494 2.105Zm1.223-2.872c.93 0 1.697-.237 2.304-.711.607-.474.91-1.119.91-1.934v-1.252h-1.593c-1.251 0-2.2.161-2.844.484-.626.322-.939.863-.939 1.621 0 1.195.72 1.792 2.162 1.792Zm15.734 2.844c-3.204 0-4.807-1.564-4.807-4.693v-7.538h-1.905v-2.93h1.905V7.91h4.096v3.215h3.13v2.93h-3.13v7.167c0 1.176.55 1.764 1.65 1.764.607 0 1.129-.095 1.565-.285v3.186c-.759.266-1.593.398-2.504.398Zm8.92.029c-1.442 0-2.608-.35-3.5-1.053-.985-.777-1.478-1.905-1.478-3.384 0-1.65.72-2.883 2.161-3.698 1.29-.72 3.148-1.081 5.576-1.081h1.678V16.5c0-.949-.18-1.64-.54-2.077-.342-.436-.968-.654-1.878-.654-1.46 0-2.304.701-2.532 2.105H84.95c.114-1.669.806-2.958 2.077-3.869 1.157-.815 2.693-1.223 4.608-1.223 1.915 0 3.413.417 4.494 1.252 1.157.91 1.735 2.332 1.735 4.266V26h-4.01v-1.792c-1.005 1.403-2.503 2.105-4.495 2.105Zm1.223-2.873c.929 0 1.697-.237 2.303-.711.607-.474.91-1.119.91-1.934v-1.252h-1.592c-1.252 0-2.2.161-2.845.484-.625.322-.938.863-.938 1.621 0 1.195.72 1.792 2.162 1.792Zm10.671-17.778h4.608v16.726h8.277V26h-12.885V5.662Zm21.632 20.65c-2.313 0-4.162-.672-5.547-2.019-1.479-1.365-2.218-3.214-2.218-5.546v-.228c0-2.313.739-4.19 2.218-5.632 1.442-1.403 3.252-2.105 5.433-2.105 2.067 0 3.755.598 5.063 1.792 1.46 1.328 2.191 3.252 2.191 5.775v1.137h-10.724c.057 1.252.398 2.219 1.024 2.902.645.663 1.536.995 2.674.995 1.782 0 2.816-.692 3.1-2.076h3.897c-.246 1.612-.986 2.854-2.219 3.726-1.213.853-2.844 1.28-4.892 1.28Zm3.129-9.357c-.133-2.219-1.214-3.328-3.243-3.328-.929 0-1.697.294-2.304.881-.588.57-.957 1.385-1.109 2.447h6.656Zm6.19-5.831h4.125v2.36c.929-1.801 2.541-2.702 4.835-2.702 1.498 0 2.703.455 3.613 1.366.929.986 1.394 2.446 1.394 4.38V26h-4.125v-8.875c0-1.024-.209-1.773-.626-2.247-.417-.493-1.081-.74-1.991-.74-.929 0-1.678.285-2.247.854-.569.55-.853 1.356-.853 2.418V26h-4.125V11.124Zm22.385 15.189c-2.01 0-3.575-.427-4.694-1.28-1.118-.853-1.716-2.086-1.792-3.698h3.84c.114.72.361 1.252.74 1.593.379.341 1.005.512 1.877.512 1.555 0 2.333-.54 2.333-1.621 0-.512-.228-.892-.683-1.138-.455-.266-1.233-.474-2.332-.626-2.01-.322-3.414-.806-4.21-1.45-.853-.664-1.28-1.726-1.28-3.186s.607-2.627 1.82-3.499c1.043-.758 2.399-1.138 4.068-1.138 1.725 0 3.119.342 4.181 1.024 1.119.759 1.773 1.953 1.963 3.584h-3.783c-.114-.626-.351-1.08-.711-1.365-.361-.284-.901-.427-1.622-.427-.663 0-1.185.143-1.564.427a1.35 1.35 0 0 0-.541 1.11c0 .473.2.824.598 1.052.417.227 1.175.417 2.275.569 1.953.265 3.385.71 4.295 1.337.986.739 1.48 1.848 1.48 3.328 0 1.592-.55 2.806-1.65 3.64-1.081.835-2.617 1.252-4.608 1.252Z" />
<defs>
<filter id="b" width="37.575" height="39.945" x="-16.413" y="-16.35" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_219_14417" stdDeviation="5.333" />
</filter>
<filter id="c" width="37.91" height="39.162" x="-14.19" y="8.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur result="effect1_foregroundBlur_219_14417" stdDeviation="5.333" />
</filter>
<clipPath id="a">
<rect width="32" height="32" fill="#fff" rx="16" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 355 354" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-232,-118)">
<path d="M565.33,118.79L253.02,118.79C241.46,118.79 232.09,128.16 232.09,139.72L232.09,450.44C232.09,462 241.46,471.37 253.02,471.37L565.33,471.37C576.89,471.37 586.26,462 586.26,450.44L586.26,139.72C586.26,128.16 576.89,118.79 565.33,118.79ZM386.85,419.57C386.85,422.58 384.4,425.03 381.39,425.03L285.11,425.03C282.1,425.03 279.65,422.58 279.65,419.57L279.65,169.43C279.65,166.42 282.1,163.96 285.11,163.96L379.87,163.96C382.88,163.96 385.33,166.41 385.33,169.43L385.33,264.64C385.33,264.64 384.81,305.61 386.85,337.76L386.85,419.58L386.85,419.57ZM537.19,419.57C537.19,423.92 532.36,426.52 528.75,424.14L484.85,395.44C482.95,394.18 480.5,394.25 478.64,395.59L440.16,423.4C438.56,424.59 436.67,424.66 435.07,424.07C433.66,423.07 432.73,421.43 432.73,419.57L432.73,225.34C437.94,224.34 443.73,223.78 450.43,223.78C483.29,223.78 537.2,242.37 537.2,294.78L537.2,419.58L537.19,419.57Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -25,6 +25,10 @@ import {
addNewService, addNewService,
checkServiceAccess, checkServiceAccess,
} from "@dokploy/server/services/user"; } from "@dokploy/server/services/user";
import {
getProviderHeaders,
type Model,
} from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
@@ -41,6 +45,58 @@ export const aiRouter = createTRPCRouter({
} }
return aiSetting; return aiSetting;
}), }),
getModels: protectedProcedure
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string().min(1) }))
.query(async ({ input }) => {
try {
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
const response = await fetch(`${input.apiUrl}/models`, { headers });
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch models: ${errorText}`);
}
const res = await response.json();
if (Array.isArray(res)) {
return res.map((model) => ({
id: model.id || model.name,
object: "model",
created: Date.now(),
owned_by: "provider",
}));
}
if (res.models) {
return res.models.map((model: any) => ({
id: model.id || model.name,
object: "model",
created: Date.now(),
owned_by: "provider",
})) as Model[];
}
if (res.data) {
return res.data as Model[];
}
const possibleModels =
(Object.values(res).find(Array.isArray) as any[]) || [];
return possibleModels.map((model) => ({
id: model.id || model.name,
object: "model",
created: Date.now(),
owned_by: "provider",
})) as Model[];
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error?.message : `Error: ${error}`,
});
}
}),
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => { create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input); return await saveAiSettings(ctx.session.activeOrganizationId, input);
}), }),

View File

@@ -298,6 +298,7 @@ export const applicationRouter = createTRPCRouter({
await updateApplication(input.applicationId, { await updateApplication(input.applicationId, {
env: input.env, env: input.env,
buildArgs: input.buildArgs, buildArgs: input.buildArgs,
buildSecrets: input.buildSecrets,
}); });
return true; return true;
}), }),
@@ -668,4 +669,49 @@ export const applicationRouter = createTRPCRouter({
return stats; return stats;
}), }),
move: protectedProcedure
.input(
z.object({
applicationId: z.string(),
targetProjectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.project.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this application",
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
});
}
// Update the application's projectId
const updatedApplication = await db
.update(applications)
.set({
projectId: input.targetProjectId,
})
.where(eq(applications.applicationId, input.applicationId))
.returning()
.then((res) => res[0]);
if (!updatedApplication) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to move application",
});
}
return updatedApplication;
}),
}); });

View File

@@ -8,7 +8,7 @@ import {
apiFindCompose, apiFindCompose,
apiRandomizeCompose, apiRandomizeCompose,
apiUpdateCompose, apiUpdateCompose,
compose, compose as composeTable,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup"; import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
import { templates } from "@/templates/templates"; import { templates } from "@/templates/templates";
@@ -24,6 +24,7 @@ import { dump } from "js-yaml";
import _ from "lodash"; import _ from "lodash";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
import { z } from "zod";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { deploy } from "@/server/utils/deploy"; import { deploy } from "@/server/utils/deploy";
@@ -157,8 +158,8 @@ export const composeRouter = createTRPCRouter({
4; 4;
const result = await db const result = await db
.delete(compose) .delete(composeTable)
.where(eq(compose.composeId, input.composeId)) .where(eq(composeTable.composeId, input.composeId))
.returning(); .returning();
const cleanupOperations = [ const cleanupOperations = [
@@ -501,4 +502,48 @@ export const composeRouter = createTRPCRouter({
const uniqueTags = _.uniq(allTags); const uniqueTags = _.uniq(allTags);
return uniqueTags; return uniqueTags;
}), }),
move: protectedProcedure
.input(
z.object({
composeId: z.string(),
targetProjectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (compose.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this compose",
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
});
}
// Update the compose's projectId
const updatedCompose = await db
.update(composeTable)
.set({
projectId: input.targetProjectId,
})
.where(eq(composeTable.composeId, input.composeId))
.returning()
.then((res) => res[0]);
if (!updatedCompose) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to move compose",
});
}
return updatedCompose;
}),
}); });

View File

@@ -21,7 +21,7 @@ import {
updateDestinationById, updateDestinationById,
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq, desc } from "drizzle-orm";
export const destinationRouter = createTRPCRouter({ export const destinationRouter = createTRPCRouter({
create: adminProcedure create: adminProcedure
@@ -98,6 +98,7 @@ export const destinationRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
return await db.query.destinations.findMany({ return await db.query.destinations.findMany({
where: eq(destinations.organizationId, ctx.session.activeOrganizationId), where: eq(destinations.organizationId, ctx.session.activeOrganizationId),
orderBy: [desc(destinations.createdAt)],
}); });
}), }),
remove: adminProcedure remove: adminProcedure

View File

@@ -8,6 +8,7 @@ import {
apiSaveEnvironmentVariablesMariaDB, apiSaveEnvironmentVariablesMariaDB,
apiSaveExternalPortMariaDB, apiSaveExternalPortMariaDB,
apiUpdateMariaDB, apiUpdateMariaDB,
mariadb as mariadbTable,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup"; import { cancelJobs } from "@/server/utils/backup";
import { import {
@@ -30,6 +31,9 @@ import {
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/server/db";
export const mariadbRouter = createTRPCRouter({ export const mariadbRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -322,4 +326,47 @@ export const mariadbRouter = createTRPCRouter({
return true; return true;
}), }),
move: protectedProcedure
.input(
z.object({
mariadbId: z.string(),
targetProjectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const mariadb = await findMariadbById(input.mariadbId);
if (mariadb.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mariadb",
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
});
}
// Update the mariadb's projectId
const updatedMariadb = await db
.update(mariadbTable)
.set({
projectId: input.targetProjectId,
})
.where(eq(mariadbTable.mariadbId, input.mariadbId))
.returning()
.then((res) => res[0]);
if (!updatedMariadb) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to move mariadb",
});
}
return updatedMariadb;
}),
}); });

View File

@@ -8,6 +8,7 @@ import {
apiSaveEnvironmentVariablesMongo, apiSaveEnvironmentVariablesMongo,
apiSaveExternalPortMongo, apiSaveExternalPortMongo,
apiUpdateMongo, apiUpdateMongo,
mongo as mongoTable,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup"; import { cancelJobs } from "@/server/utils/backup";
import { import {
@@ -30,6 +31,9 @@ import {
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/server/db";
export const mongoRouter = createTRPCRouter({ export const mongoRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -336,4 +340,47 @@ export const mongoRouter = createTRPCRouter({
return true; return true;
}), }),
move: protectedProcedure
.input(
z.object({
mongoId: z.string(),
targetProjectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const mongo = await findMongoById(input.mongoId);
if (mongo.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mongo",
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
});
}
// Update the mongo's projectId
const updatedMongo = await db
.update(mongoTable)
.set({
projectId: input.targetProjectId,
})
.where(eq(mongoTable.mongoId, input.mongoId))
.returning()
.then((res) => res[0]);
if (!updatedMongo) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to move mongo",
});
}
return updatedMongo;
}),
}); });

View File

@@ -8,6 +8,7 @@ import {
apiSaveEnvironmentVariablesMySql, apiSaveEnvironmentVariablesMySql,
apiSaveExternalPortMySql, apiSaveExternalPortMySql,
apiUpdateMySql, apiUpdateMySql,
mysql as mysqlTable,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -32,6 +33,9 @@ import {
updateMySqlById, updateMySqlById,
} from "@dokploy/server"; } from "@dokploy/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { db } from "@/server/db";
import { z } from "zod";
export const mysqlRouter = createTRPCRouter({ export const mysqlRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -332,4 +336,47 @@ export const mysqlRouter = createTRPCRouter({
return true; return true;
}), }),
move: protectedProcedure
.input(
z.object({
mysqlId: z.string(),
targetProjectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const mysql = await findMySqlById(input.mysqlId);
if (mysql.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this mysql",
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
});
}
// Update the mysql's projectId
const updatedMysql = await db
.update(mysqlTable)
.set({
projectId: input.targetProjectId,
})
.where(eq(mysqlTable.mysqlId, input.mysqlId))
.returning()
.then((res) => res[0]);
if (!updatedMysql) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to move mysql",
});
}
return updatedMysql;
}),
}); });

View File

@@ -8,6 +8,7 @@ import {
apiSaveEnvironmentVariablesPostgres, apiSaveEnvironmentVariablesPostgres,
apiSaveExternalPortPostgres, apiSaveExternalPortPostgres,
apiUpdatePostgres, apiUpdatePostgres,
postgres as postgresTable,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup"; import { cancelJobs } from "@/server/utils/backup";
import { import {
@@ -30,6 +31,9 @@ import {
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/server/db";
export const postgresRouter = createTRPCRouter({ export const postgresRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -352,4 +356,49 @@ export const postgresRouter = createTRPCRouter({
return true; return true;
}), }),
move: protectedProcedure
.input(
z.object({
postgresId: z.string(),
targetProjectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const postgres = await findPostgresById(input.postgresId);
if (
postgres.project.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this postgres",
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
});
}
// Update the postgres's projectId
const updatedPostgres = await db
.update(postgresTable)
.set({
projectId: input.targetProjectId,
})
.where(eq(postgresTable.postgresId, input.postgresId))
.returning()
.then((res) => res[0]);
if (!updatedPostgres) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to move postgres",
});
}
return updatedPostgres;
}),
}); });

View File

@@ -8,6 +8,7 @@ import {
apiSaveEnvironmentVariablesRedis, apiSaveEnvironmentVariablesRedis,
apiSaveExternalPortRedis, apiSaveExternalPortRedis,
apiUpdateRedis, apiUpdateRedis,
redis as redisTable,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -30,6 +31,9 @@ import {
updateRedisById, updateRedisById,
} from "@dokploy/server"; } from "@dokploy/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { db } from "@/server/db";
import { z } from "zod";
export const redisRouter = createTRPCRouter({ export const redisRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
@@ -316,4 +320,47 @@ export const redisRouter = createTRPCRouter({
return true; return true;
}), }),
move: protectedProcedure
.input(
z.object({
redisId: z.string(),
targetProjectId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const redis = await findRedisById(input.redisId);
if (redis.project.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move this redis",
});
}
const targetProject = await findProjectById(input.targetProjectId);
if (targetProject.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to move to this project",
});
}
// Update the redis's projectId
const updatedRedis = await db
.update(redisTable)
.set({
projectId: input.targetProjectId,
})
.where(eq(redisTable.redisId, input.redisId))
.returning()
.then((res) => res[0]);
if (!updatedRedis) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to move redis",
});
}
return updatedRedis;
}),
}); });

View File

@@ -28,7 +28,6 @@ import {
getDokployImageTag, getDokployImageTag,
getUpdateData, getUpdateData,
initializeTraefik, initializeTraefik,
logRotationManager,
parseRawConfig, parseRawConfig,
paths, paths,
prepareEnvironmentVariables, prepareEnvironmentVariables,
@@ -53,6 +52,9 @@ import {
writeConfig, writeConfig,
writeMainConfig, writeMainConfig,
writeTraefikConfigInPath, writeTraefikConfigInPath,
startLogCleanup,
stopLogCleanup,
getLogCleanupStatus,
} from "@dokploy/server"; } from "@dokploy/server";
import { checkGPUStatus, setupGPUSupport } from "@dokploy/server"; import { checkGPUStatus, setupGPUSupport } from "@dokploy/server";
import { generateOpenApiDocument } from "@dokploy/trpc-openapi"; import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
@@ -577,48 +579,43 @@ export const settingsRouter = createTRPCRouter({
totalCount: 0, totalCount: 0,
}; };
} }
const rawConfig = readMonitoringConfig(); const rawConfig = readMonitoringConfig(
!!input.dateRange?.start && !!input.dateRange?.end,
);
const parsedConfig = parseRawConfig( const parsedConfig = parseRawConfig(
rawConfig as string, rawConfig as string,
input.page, input.page,
input.sort, input.sort,
input.search, input.search,
input.status, input.status,
input.dateRange,
); );
return parsedConfig; return parsedConfig;
}), }),
readStats: adminProcedure.query(() => { readStats: adminProcedure
if (IS_CLOUD) {
return [];
}
const rawConfig = readMonitoringConfig();
const processedLogs = processLogs(rawConfig as string);
return processedLogs || [];
}),
getLogRotateStatus: adminProcedure.query(async () => {
if (IS_CLOUD) {
return true;
}
return await logRotationManager.getStatus();
}),
toggleLogRotate: adminProcedure
.input( .input(
z.object({ z
enable: z.boolean(), .object({
}), dateRange: z
.object({
start: z.string().optional(),
end: z.string().optional(),
})
.optional(),
})
.optional(),
) )
.mutation(async ({ input }) => { .query(({ input }) => {
if (IS_CLOUD) { if (IS_CLOUD) {
return true; return [];
} }
if (input.enable) { const rawConfig = readMonitoringConfig(
await logRotationManager.activate(); !!input?.dateRange?.start || !!input?.dateRange?.end,
} else { );
await logRotationManager.deactivate(); const processedLogs = processLogs(rawConfig as string, input?.dateRange);
} return processedLogs || [];
return true;
}), }),
haveActivateRequests: adminProcedure.query(async () => { haveActivateRequests: adminProcedure.query(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
@@ -820,10 +817,20 @@ export const settingsRouter = createTRPCRouter({
}); });
} }
}), }),
updateLogCleanup: adminProcedure
.input(
z.object({
cronExpression: z.string().nullable(),
}),
)
.mutation(async ({ input }) => {
if (input.cronExpression) {
return startLogCleanup(input.cronExpression);
}
return stopLogCleanup();
}),
getLogCleanupStatus: adminProcedure.query(async () => {
return getLogCleanupStatus();
}),
}); });
// {
// "Parallelism": 1,
// "Delay": 10000000000,
// "FailureAction": "rollback",
// "Order": "start-first"
// }

View File

@@ -0,0 +1,96 @@
services:
pg-compeng:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_PASSWORD: "postgres"
POSTGRES_DB: postgres
POSTGRES_USER: postgres
control-api:
image: ghcr.io/datalens-tech/datalens-control-api:0.2192.0
restart: always
environment:
BI_API_UWSGI_WORKERS_COUNT: 4
CONNECTOR_AVAILABILITY_VISIBLE: "clickhouse,postgres,chyt,ydb,mysql,greenplum,mssql,appmetrica_api,metrika_api"
RQE_FORCE_OFF: 1
DL_CRY_ACTUAL_KEY_ID: key_1
DL_CRY_KEY_VAL_ID_key_1: "h1ZpilcYLYRdWp7Nk8X1M1kBPiUi8rdjz9oBfHyUKIk="
RQE_SECRET_KEY: ""
US_HOST: "http://us:8083"
US_MASTER_TOKEN: "fake-us-master-token"
depends_on:
- us
data-api:
container_name: datalens-data-api
image: ghcr.io/datalens-tech/datalens-data-api:0.2192.0
restart: always
environment:
GUNICORN_WORKERS_COUNT: 5
RQE_FORCE_OFF: 1
CACHES_ON: 0
MUTATIONS_CACHES_ON: 0
RQE_SECRET_KEY: ""
DL_CRY_ACTUAL_KEY_ID: key_1
DL_CRY_KEY_VAL_ID_key_1: "h1ZpilcYLYRdWp7Nk8X1M1kBPiUi8rdjz9oBfHyUKIk="
BI_COMPENG_PG_ON: 1
BI_COMPENG_PG_URL: "postgresql://postgres:postgres@pg-compeng:5432/postgres"
US_HOST: "http://us:8083"
US_MASTER_TOKEN: "fake-us-master-token"
depends_on:
- us
- pg-compeng
pg-us:
container_name: datalens-pg-us
image: postgres:16-alpine
restart: always
environment:
POSTGRES_DB: us-db-ci_purgeable
POSTGRES_USER: us
POSTGRES_PASSWORD: us
volumes:
- ${VOLUME_US:-./metadata}:/var/lib/postgresql/data
us:
image: ghcr.io/datalens-tech/datalens-us:0.310.0
restart: always
depends_on:
- pg-us
environment:
APP_INSTALLATION: "opensource"
APP_ENV: "prod"
MASTER_TOKEN: "fake-us-master-token"
POSTGRES_DSN_LIST: ${METADATA_POSTGRES_DSN_LIST:-postgres://us:us@pg-us:5432/us-db-ci_purgeable}
SKIP_INSTALL_DB_EXTENSIONS: ${METADATA_SKIP_INSTALL_DB_EXTENSIONS:-0}
USE_DEMO_DATA: ${USE_DEMO_DATA:-0}
HC: ${HC:-0}
NODE_EXTRA_CA_CERTS: /certs/root.crt
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./certs:/certs
datalens:
image: ghcr.io/datalens-tech/datalens-ui:0.2601.0
restart: always
ports:
- ${UI_PORT:-8080}:8080
depends_on:
- us
- control-api
- data-api
environment:
APP_MODE: "full"
APP_ENV: "production"
APP_INSTALLATION: "opensource"
AUTH_POLICY: "disabled"
US_ENDPOINT: "http://us:8083"
BI_API_ENDPOINT: "http://control-api:8080"
BI_DATA_ENDPOINT: "http://data-api:8080"
US_MASTER_TOKEN: "fake-us-master-token"
NODE_EXTRA_CA_CERTS: "/usr/local/share/ca-certificates/cert.pem"
HC: ${HC:-0}
YANDEX_MAP_ENABLED: ${YANDEX_MAP_ENABLED:-0}
YANDEX_MAP_TOKEN: ${YANDEX_MAP_TOKEN:-0}

View File

@@ -0,0 +1,23 @@
import {
type DomainSchema,
type Schema,
type Template,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const domains: DomainSchema[] = [
{
host: generateRandomDomain(schema),
port: 8080,
serviceName: "datalens",
},
];
const envs = ["HC=1"];
return {
envs,
domains,
};
}

View File

@@ -0,0 +1,45 @@
services:
web:
image: ghcr.io/hoarder-app/hoarder:0.22.0
restart: unless-stopped
volumes:
- hoarder-data:/data
ports:
- 3000
environment:
- DISABLE_SIGNUPS
- NEXTAUTH_URL
- NEXTAUTH_SECRET
- MEILI_ADDR=http://meilisearch:7700
- BROWSER_WEB_URL=http://chrome:9222
- DATA_DIR=/data
chrome:
image: gcr.io/zenika-hub/alpine-chrome:124
restart: unless-stopped
command:
- --no-sandbox
- --disable-gpu
- --disable-dev-shm-usage
- --remote-debugging-address=0.0.0.0
- --remote-debugging-port=9222
- --hide-scrollbars
meilisearch:
image: getmeili/meilisearch:v1.6
restart: unless-stopped
environment:
- MEILI_MASTER_KEY
- MEILI_NO_ANALYTICS="true"
volumes:
- meilisearch-data:/meili_data
healthcheck:
test:
- CMD
- curl
- '-f'
- 'http://127.0.0.1:7700/health'
interval: 2s
timeout: 10s
retries: 15
volumes:
meilisearch-data:
hoarder-data:

View File

@@ -0,0 +1,34 @@
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generatePassword,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema);
const postgresPassword = generatePassword();
const nextSecret = generateBase64(32);
const meiliMasterKey = generateBase64(32);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 3000,
serviceName: "web",
},
];
const envs = [
`NEXTAUTH_SECRET=${nextSecret}`,
`MEILI_MASTER_KEY=${meiliMasterKey}`,
`NEXTAUTH_URL=http://${mainDomain}`,
];
return {
domains,
envs,
};
}

View File

@@ -66,7 +66,7 @@ services:
timeout: 10s timeout: 10s
retries: 3 retries: 3
superset_redis: superset_redis:
image: redis image: redis
restart: always restart: always
volumes: volumes:

View File

@@ -108,6 +108,20 @@ export const templates: TemplateData[] = [
tags: ["monitoring"], tags: ["monitoring"],
load: () => import("./grafana/index").then((m) => m.generate), load: () => import("./grafana/index").then((m) => m.generate),
}, },
{
id: "datalens",
name: "DataLens",
version: "1.23.0",
description: "A modern, scalable business intelligence and data visualization system.",
logo: "datalens.svg",
links: {
github: "https://github.com/datalens-tech/datalens",
website: "https://datalens.tech/",
docs: "https://datalens.tech/docs/",
},
tags: ["analytics", "self-hosted", "bi", "monitoring"],
load: () => import("./datalens/index").then((m) => m.generate),
},
{ {
id: "directus", id: "directus",
name: "Directus", name: "Directus",
@@ -707,6 +721,21 @@ export const templates: TemplateData[] = [
tags: ["self-hosted", "open-source", "manager"], tags: ["self-hosted", "open-source", "manager"],
load: () => import("./hi-events/index").then((m) => m.generate), load: () => import("./hi-events/index").then((m) => m.generate),
}, },
{
id: "hoarder",
name: "Hoarder",
version: "0.22.0",
description:
'Hoarder is an open source "Bookmark Everything" app that uses AI for automatically tagging the content you throw at it.',
logo: "hoarder.svg",
links: {
github: "https://github.com/hoarder/hoarder",
website: "https://hoarder.app/",
docs: "https://docs.hoarder.app/",
},
tags: ["self-hosted", "bookmarks", "link-sharing"],
load: () => import("./hoarder/index").then((m) => m.generate),
},
{ {
id: "windows", id: "windows",
name: "Windows (dockerized)", name: "Windows (dockerized)",

View File

@@ -17,15 +17,17 @@ services:
retries: 5 retries: 5
zipline: zipline:
image: ghcr.io/diced/zipline:3.7.9 image: ghcr.io/diced/zipline:4
restart: unless-stopped restart: unless-stopped
environment: environment:
- CORE_RETURN_HTTPS=${ZIPLINE_RETURN_HTTPS} - CORE_RETURN_HTTPS=${ZIPLINE_RETURN_HTTPS}
- CORE_SECRET=${ZIPLINE_SECRET} - CORE_SECRET=${ZIPLINE_SECRET}
- CORE_HOST=0.0.0.0 - CORE_HOSTNAME=0.0.0.0
- CORE_PORT=${ZIPLINE_PORT} - CORE_PORT=${ZIPLINE_PORT}
- CORE_DATABASE_URL=postgres://postgres:postgres@postgres/postgres - DATABASE_URL=postgres://postgres:postgres@postgres/postgres
- CORE_LOGGER=${ZIPLINE_LOGGER} - CORE_LOGGER=${ZIPLINE_LOGGER}
- DATASOURCE_TYPE=local
- DATASOURCE_LOCAL_DIRECTORY=./uploads
volumes: volumes:
- "../files/uploads:/zipline/uploads" - "../files/uploads:/zipline/uploads"
- "../files/public:/zipline/public" - "../files/public:/zipline/public"

View File

@@ -129,6 +129,7 @@ export const applications = pgTable("application", {
false, false,
), ),
buildArgs: text("buildArgs"), buildArgs: text("buildArgs"),
buildSecrets: json("buildSecrets").$type<Record<string, string>>(),
memoryReservation: text("memoryReservation"), memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"), memoryLimit: text("memoryLimit"),
cpuReservation: text("cpuReservation"), cpuReservation: text("cpuReservation"),
@@ -353,6 +354,7 @@ const createSchema = createInsertSchema(applications, {
autoDeploy: z.boolean(), autoDeploy: z.boolean(),
env: z.string().optional(), env: z.string().optional(),
buildArgs: z.string().optional(), buildArgs: z.string().optional(),
buildSecrets: z.record(z.string(), z.string()).optional(),
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
memoryReservation: z.string().optional(), memoryReservation: z.string().optional(),
@@ -499,11 +501,12 @@ export const apiSaveGitProvider = createSchema
}), }),
); );
export const apiSaveEnvironmentVariables = createSchema export const apiSaveEnvironmentVariables = z
.pick({ .object({
applicationId: true, applicationId: z.string(),
env: true, env: z.string().optional(),
buildArgs: true, buildArgs: z.string().optional(),
buildSecrets: z.record(z.string(), z.string()).optional(),
}) })
.required(); .required();

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core"; import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
@@ -21,6 +21,7 @@ export const destinations = pgTable("destination", {
organizationId: text("organizationId") organizationId: text("organizationId")
.notNull() .notNull()
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
createdAt: timestamp("createdAt").notNull().defaultNow(),
}); });
export const destinationsRelations = relations( export const destinationsRelations = relations(

View File

@@ -149,6 +149,7 @@ table application {
previewLimit integer [default: 3] previewLimit integer [default: 3]
isPreviewDeploymentsActive boolean [default: false] isPreviewDeploymentsActive boolean [default: false]
buildArgs text buildArgs text
buildSecrets json
memoryReservation text memoryReservation text
memoryLimit text memoryLimit text
cpuReservation text cpuReservation text

View File

@@ -53,7 +53,7 @@ export const users_temp = pgTable("user_temp", {
letsEncryptEmail: text("letsEncryptEmail"), letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"), sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false), enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
enableLogRotation: boolean("enableLogRotation").notNull().default(false), logCleanupCron: text("logCleanupCron"),
// Metrics // Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false), enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
metricsConfig: jsonb("metricsConfig") metricsConfig: jsonb("metricsConfig")
@@ -250,6 +250,12 @@ export const apiReadStatsLogs = z.object({
status: z.string().array().optional(), status: z.string().array().optional(),
search: z.string().optional(), search: z.string().optional(),
sort: z.object({ id: z.string(), desc: z.boolean() }).optional(), sort: z.object({ id: z.string(), desc: z.boolean() }).optional(),
dateRange: z
.object({
start: z.string().optional(),
end: z.string().optional(),
})
.optional(),
}); });
export const apiUpdateWebServerMonitoring = z.object({ export const apiUpdateWebServerMonitoring = z.object({
@@ -305,4 +311,5 @@ export const apiUpdateUser = createSchema.partial().extend({
}), }),
}) })
.optional(), .optional(),
logCleanupCron: z.string().optional().nullable(),
}); });

View File

@@ -116,3 +116,9 @@ export * from "./db/validations/index";
export * from "./utils/gpu-setup"; export * from "./utils/gpu-setup";
export * from "./lib/auth"; export * from "./lib/auth";
export {
startLogCleanup,
stopLogCleanup,
getLogCleanupStatus,
} from "./utils/access-log/handler";

View File

@@ -136,24 +136,26 @@ export const getContainersByAppNameMatch = async (
result = stdout.trim().split("\n"); result = stdout.trim().split("\n");
} }
const containers = result.map((line) => { const containers = result
const parts = line.split(" | "); .map((line) => {
const containerId = parts[0] const parts = line.split(" | ");
? parts[0].replace("CONTAINER ID : ", "").trim() const containerId = parts[0]
: "No container id"; ? parts[0].replace("CONTAINER ID : ", "").trim()
const name = parts[1] : "No container id";
? parts[1].replace("Name: ", "").trim() const name = parts[1]
: "No container name"; ? parts[1].replace("Name: ", "").trim()
: "No container name";
const state = parts[2] const state = parts[2]
? parts[2].replace("State: ", "").trim() ? parts[2].replace("State: ", "").trim()
: "No state"; : "No state";
return { return {
containerId, containerId,
name, name,
state, state,
}; };
}); })
.sort((a, b) => a.name.localeCompare(b.name));
return containers || []; return containers || [];
} catch (_error) {} } catch (_error) {}
@@ -190,28 +192,30 @@ export const getStackContainersByAppName = async (
result = stdout.trim().split("\n"); result = stdout.trim().split("\n");
} }
const containers = result.map((line) => { const containers = result
const parts = line.split(" | "); .map((line) => {
const containerId = parts[0] const parts = line.split(" | ");
? parts[0].replace("CONTAINER ID : ", "").trim() const containerId = parts[0]
: "No container id"; ? parts[0].replace("CONTAINER ID : ", "").trim()
const name = parts[1] : "No container id";
? parts[1].replace("Name: ", "").trim() const name = parts[1]
: "No container name"; ? parts[1].replace("Name: ", "").trim()
: "No container name";
const state = parts[2] const state = parts[2]
? parts[2].replace("State: ", "").trim().toLowerCase() ? parts[2].replace("State: ", "").trim().toLowerCase()
: "No state"; : "No state";
const node = parts[3] const node = parts[3]
? parts[3].replace("Node: ", "").trim() ? parts[3].replace("Node: ", "").trim()
: "No specific node"; : "No specific node";
return { return {
containerId, containerId,
name, name,
state, state,
node, node,
}; };
}); })
.sort((a, b) => a.name.localeCompare(b.name));
return containers || []; return containers || [];
} catch (_error) {} } catch (_error) {}
@@ -249,29 +253,31 @@ export const getServiceContainersByAppName = async (
result = stdout.trim().split("\n"); result = stdout.trim().split("\n");
} }
const containers = result.map((line) => { const containers = result
const parts = line.split(" | "); .map((line) => {
const containerId = parts[0] const parts = line.split(" | ");
? parts[0].replace("CONTAINER ID : ", "").trim() const containerId = parts[0]
: "No container id"; ? parts[0].replace("CONTAINER ID : ", "").trim()
const name = parts[1] : "No container id";
? parts[1].replace("Name: ", "").trim() const name = parts[1]
: "No container name"; ? parts[1].replace("Name: ", "").trim()
: "No container name";
const state = parts[2] const state = parts[2]
? parts[2].replace("State: ", "").trim().toLowerCase() ? parts[2].replace("State: ", "").trim().toLowerCase()
: "No state"; : "No state";
const node = parts[3] const node = parts[3]
? parts[3].replace("Node: ", "").trim() ? parts[3].replace("Node: ", "").trim()
: "No specific node"; : "No specific node";
return { return {
containerId, containerId,
name, name,
state, state,
node, node,
}; };
}); })
.sort((a, b) => a.name.localeCompare(b.name));
return containers || []; return containers || [];
} catch (_error) {} } catch (_error) {}
@@ -306,23 +312,25 @@ export const getContainersByAppLabel = async (
const lines = stdout.trim().split("\n"); const lines = stdout.trim().split("\n");
const containers = lines.map((line) => { const containers = lines
const parts = line.split(" | "); .map((line) => {
const containerId = parts[0] const parts = line.split(" | ");
? parts[0].replace("CONTAINER ID : ", "").trim() const containerId = parts[0]
: "No container id"; ? parts[0].replace("CONTAINER ID : ", "").trim()
const name = parts[1] : "No container id";
? parts[1].replace("Name: ", "").trim() const name = parts[1]
: "No container name"; ? parts[1].replace("Name: ", "").trim()
const state = parts[2] : "No container name";
? parts[2].replace("State: ", "").trim() const state = parts[2]
: "No state"; ? parts[2].replace("State: ", "").trim()
return { : "No state";
containerId, return {
name, containerId,
state, name,
}; state,
}); };
})
.sort((a, b) => a.name.localeCompare(b.name));
return containers || []; return containers || [];
} catch (_error) {} } catch (_error) {}

View File

@@ -1,121 +1,77 @@
import { IS_CLOUD, paths } from "@dokploy/server/constants"; import { paths } from "@dokploy/server/constants";
import { type RotatingFileStream, createStream } from "rotating-file-stream";
import { execAsync } from "../process/execAsync"; import { execAsync } from "../process/execAsync";
import { findAdmin } from "@dokploy/server/services/admin"; import { findAdmin } from "@dokploy/server/services/admin";
import { updateUser } from "@dokploy/server/services/user"; import { updateUser } from "@dokploy/server/services/user";
import { scheduleJob, scheduledJobs } from "node-schedule";
class LogRotationManager { const LOG_CLEANUP_JOB_NAME = "access-log-cleanup";
private static instance: LogRotationManager;
private stream: RotatingFileStream | null = null;
private constructor() { export const startLogCleanup = async (
if (IS_CLOUD) { cronExpression = "0 0 * * *",
return; ): Promise<boolean> => {
} try {
this.initialize().catch(console.error);
}
public static getInstance(): LogRotationManager {
if (!LogRotationManager.instance) {
LogRotationManager.instance = new LogRotationManager();
}
return LogRotationManager.instance;
}
private async initialize(): Promise<void> {
const isActive = await this.getStateFromDB();
if (isActive) {
await this.activateStream();
}
}
private async getStateFromDB(): Promise<boolean> {
const admin = await findAdmin();
return admin?.user.enableLogRotation ?? false;
}
private async setStateInDB(active: boolean): Promise<void> {
const admin = await findAdmin();
if (!admin) {
return;
}
await updateUser(admin.user.id, {
enableLogRotation: active,
});
}
private async activateStream(): Promise<void> {
const { DYNAMIC_TRAEFIK_PATH } = paths(); const { DYNAMIC_TRAEFIK_PATH } = paths();
if (this.stream) {
await this.deactivateStream(); const existingJob = scheduledJobs[LOG_CLEANUP_JOB_NAME];
if (existingJob) {
existingJob.cancel();
} }
this.stream = createStream("access.log", { scheduleJob(LOG_CLEANUP_JOB_NAME, cronExpression, async () => {
size: "100M", try {
interval: "1d", await execAsync(
path: DYNAMIC_TRAEFIK_PATH, `tail -n 1000 ${DYNAMIC_TRAEFIK_PATH}/access.log > ${DYNAMIC_TRAEFIK_PATH}/access.log.tmp && mv ${DYNAMIC_TRAEFIK_PATH}/access.log.tmp ${DYNAMIC_TRAEFIK_PATH}/access.log`,
rotate: 6, );
compress: "gzip",
});
this.stream.on("rotation", this.handleRotation.bind(this)); await execAsync("docker exec dokploy-traefik kill -USR1 1");
} } catch (error) {
console.error("Error during log cleanup:", error);
private async deactivateStream(): Promise<void> {
return new Promise<void>((resolve) => {
if (this.stream) {
this.stream.end(() => {
this.stream = null;
resolve();
});
} else {
resolve();
} }
}); });
}
public async activate(): Promise<boolean> { const admin = await findAdmin();
const currentState = await this.getStateFromDB(); if (admin) {
if (currentState) { await updateUser(admin.user.id, {
return true; logCleanupCron: cronExpression,
});
} }
await this.setStateInDB(true);
await this.activateStream();
return true; return true;
} catch (_) {
return false;
} }
};
public async deactivate(): Promise<boolean> { export const stopLogCleanup = async (): Promise<boolean> => {
console.log("Deactivating log rotation..."); try {
const currentState = await this.getStateFromDB(); const existingJob = scheduledJobs[LOG_CLEANUP_JOB_NAME];
if (!currentState) { if (existingJob) {
console.log("Log rotation is already inactive in DB"); existingJob.cancel();
return true; }
// Update database
const admin = await findAdmin();
if (admin) {
await updateUser(admin.user.id, {
logCleanupCron: null,
});
} }
await this.setStateInDB(false);
await this.deactivateStream();
console.log("Log rotation deactivated successfully");
return true; return true;
} catch (error) {
console.error("Error stopping log cleanup:", error);
return false;
} }
};
private async handleRotation() { export const getLogCleanupStatus = async (): Promise<{
try { enabled: boolean;
const status = await this.getStatus(); cronExpression: string | null;
if (!status) { }> => {
await this.deactivateStream(); const admin = await findAdmin();
} const cronExpression = admin?.user.logCleanupCron ?? null;
await execAsync( return {
"docker kill -s USR1 $(docker ps -q --filter name=dokploy-traefik)", enabled: cronExpression !== null,
); cronExpression,
console.log("USR1 Signal send to Traefik"); };
} catch (error) { };
console.error("Error sending USR1 Signal to Traefik:", error);
}
}
public async getStatus(): Promise<boolean> {
const dbState = await this.getStateFromDB();
return dbState;
}
}
export const logRotationManager = LogRotationManager.getInstance();

View File

@@ -6,14 +6,21 @@ interface HourlyData {
count: number; count: number;
} }
export function processLogs(logString: string): HourlyData[] { export function processLogs(
logString: string,
dateRange?: { start?: string; end?: string },
): HourlyData[] {
if (_.isEmpty(logString)) { if (_.isEmpty(logString)) {
return []; return [];
} }
const hourlyData = _(logString) const hourlyData = _(logString)
.split("\n") .split("\n")
.compact() .filter((line) => {
const trimmed = line.trim();
// Check if the line starts with { and ends with } to ensure it's a potential JSON object
return trimmed !== "" && trimmed.startsWith("{") && trimmed.endsWith("}");
})
.map((entry) => { .map((entry) => {
try { try {
const log: LogEntry = JSON.parse(entry); const log: LogEntry = JSON.parse(entry);
@@ -21,6 +28,20 @@ export function processLogs(logString: string): HourlyData[] {
return null; return null;
} }
const date = new Date(log.StartUTC); const date = new Date(log.StartUTC);
if (dateRange?.start || dateRange?.end) {
const logDate = date.getTime();
const start = dateRange?.start
? new Date(dateRange.start).getTime()
: 0;
const end = dateRange?.end
? new Date(dateRange.end).getTime()
: Number.POSITIVE_INFINITY;
if (logDate < start || logDate > end) {
return null;
}
}
return `${date.toISOString().slice(0, 13)}:00:00Z`; return `${date.toISOString().slice(0, 13)}:00:00Z`;
} catch (error) { } catch (error) {
console.error("Error parsing log entry:", error); console.error("Error parsing log entry:", error);
@@ -51,21 +72,46 @@ export function parseRawConfig(
sort?: SortInfo, sort?: SortInfo,
search?: string, search?: string,
status?: string[], status?: string[],
dateRange?: { start?: string; end?: string },
): { data: LogEntry[]; totalCount: number } { ): { data: LogEntry[]; totalCount: number } {
try { try {
if (_.isEmpty(rawConfig)) { if (_.isEmpty(rawConfig)) {
return { data: [], totalCount: 0 }; return { data: [], totalCount: 0 };
} }
// Split logs into chunks to avoid memory issues
let parsedLogs = _(rawConfig) let parsedLogs = _(rawConfig)
.split("\n") .split("\n")
.filter((line) => {
const trimmed = line.trim();
return (
trimmed !== "" && trimmed.startsWith("{") && trimmed.endsWith("}")
);
})
.map((line) => {
try {
return JSON.parse(line) as LogEntry;
} catch (error) {
console.error("Error parsing log line:", error);
return null;
}
})
.compact() .compact()
.map((line) => JSON.parse(line) as LogEntry)
.value(); .value();
parsedLogs = parsedLogs.filter( // Apply date range filter if provided
(log) => log.ServiceName !== "dokploy-service-app@file", if (dateRange?.start || dateRange?.end) {
); parsedLogs = parsedLogs.filter((log) => {
const logDate = new Date(log.StartUTC).getTime();
const start = dateRange?.start
? new Date(dateRange.start).getTime()
: 0;
const end = dateRange?.end
? new Date(dateRange.end).getTime()
: Number.POSITIVE_INFINITY;
return logDate >= start && logDate <= end;
});
}
if (search) { if (search) {
parsedLogs = parsedLogs.filter((log) => parsedLogs = parsedLogs.filter((log) =>
@@ -78,6 +124,7 @@ export function parseRawConfig(
status.some((range) => isStatusInRange(log.DownstreamStatus, range)), status.some((range) => isStatusInRange(log.DownstreamStatus, range)),
); );
} }
const totalCount = parsedLogs.length; const totalCount = parsedLogs.length;
if (sort) { if (sort) {
@@ -101,6 +148,7 @@ export function parseRawConfig(
throw new Error("Failed to parse rawConfig"); throw new Error("Failed to parse rawConfig");
} }
} }
const isStatusInRange = (status: number, range: string) => { const isStatusInRange = (status: number, range: string) => {
switch (range) { switch (range) {
case "info": case "info":

View File

@@ -17,7 +17,7 @@ function getProviderName(apiUrl: string) {
if (apiUrl.includes("localhost:11434") || apiUrl.includes("ollama")) if (apiUrl.includes("localhost:11434") || apiUrl.includes("ollama"))
return "ollama"; return "ollama";
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra"; if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
throw new Error(`Unsupported AI provider for URL: ${apiUrl}`); return "custom";
} }
export function selectAIProvider(config: { apiUrl: string; apiKey: string }) { export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
@@ -67,7 +67,46 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
baseURL: config.apiUrl, baseURL: config.apiUrl,
apiKey: config.apiKey, apiKey: config.apiKey,
}); });
case "custom":
return createOpenAICompatible({
name: "custom",
baseURL: config.apiUrl,
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
default: default:
throw new Error(`Unsupported AI provider: ${providerName}`); throw new Error(`Unsupported AI provider: ${providerName}`);
} }
} }
export const getProviderHeaders = (
apiUrl: string,
apiKey: string,
): Record<string, string> => {
// Anthropic
if (apiUrl.includes("anthropic")) {
return {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
};
}
// Mistral
if (apiUrl.includes("mistral")) {
return {
Authorization: apiKey,
};
}
// Default (OpenAI style)
return {
Authorization: `Bearer ${apiKey}`,
};
};
export interface Model {
id: string;
object: string;
created: number;
owned_by: string;
}

View File

@@ -12,6 +12,7 @@ import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql"; import { runMySqlBackup } from "./mysql";
import { runPostgresBackup } from "./postgres"; import { runPostgresBackup } from "./postgres";
import { findAdmin } from "../../services/admin"; import { findAdmin } from "../../services/admin";
import { startLogCleanup } from "../access-log/handler";
export const initCronJobs = async () => { export const initCronJobs = async () => {
console.log("Setting up cron jobs...."); console.log("Setting up cron jobs....");
@@ -168,4 +169,8 @@ export const initCronJobs = async () => {
} }
} }
} }
if (admin?.user.logCleanupCron) {
await startLogCleanup(admin.user.logCleanupCron);
}
}; };

View File

@@ -12,8 +12,14 @@ export const buildCustomDocker = async (
application: ApplicationNested, application: ApplicationNested,
writeStream: WriteStream, writeStream: WriteStream,
) => { ) => {
const { appName, env, publishDirectory, buildArgs, dockerBuildStage } = const {
application; appName,
env,
publishDirectory,
buildArgs,
buildSecrets,
dockerBuildStage,
} = application;
const dockerFilePath = getBuildAppDirectory(application); const dockerFilePath = getBuildAppDirectory(application);
try { try {
const image = `${appName}`; const image = `${appName}`;
@@ -25,6 +31,10 @@ export const buildCustomDocker = async (
application.project.env, application.project.env,
); );
const secrets = buildSecrets
? Object.entries(buildSecrets).map(([key, value]) => `${key}=${value}`)
: [];
const dockerContextPath = getDockerContextPath(application); const dockerContextPath = getDockerContextPath(application);
const commandArgs = ["build", "-t", image, "-f", dockerFilePath, "."]; const commandArgs = ["build", "-t", image, "-f", dockerFilePath, "."];
@@ -36,6 +46,12 @@ export const buildCustomDocker = async (
for (const arg of args) { for (const arg of args) {
commandArgs.push("--build-arg", arg); commandArgs.push("--build-arg", arg);
} }
for (const secret of secrets) {
const [key] = secret.split("=");
commandArgs.push("--secret", `id=${key},env=${key}`);
}
/* /*
Do not generate an environment file when publishDirectory is specified, Do not generate an environment file when publishDirectory is specified,
as it could be publicly exposed. as it could be publicly exposed.
@@ -54,6 +70,10 @@ export const buildCustomDocker = async (
}, },
{ {
cwd: dockerContextPath || defaultContextPath, cwd: dockerContextPath || defaultContextPath,
env: {
...process.env,
...Object.fromEntries(secrets.map((s) => s.split("="))),
},
}, },
); );
} catch (error) { } catch (error) {
@@ -65,8 +85,14 @@ export const getDockerCommand = (
application: ApplicationNested, application: ApplicationNested,
logPath: string, logPath: string,
) => { ) => {
const { appName, env, publishDirectory, buildArgs, dockerBuildStage } = const {
application; appName,
env,
publishDirectory,
buildArgs,
buildSecrets,
dockerBuildStage,
} = application;
const dockerFilePath = getBuildAppDirectory(application); const dockerFilePath = getBuildAppDirectory(application);
try { try {
@@ -79,6 +105,10 @@ export const getDockerCommand = (
application.project.env, application.project.env,
); );
const secrets = buildSecrets
? Object.entries(buildSecrets).map(([key, value]) => `${key}=${value}`)
: [];
const dockerContextPath = const dockerContextPath =
getDockerContextPath(application) || defaultContextPath; getDockerContextPath(application) || defaultContextPath;
@@ -92,6 +122,11 @@ export const getDockerCommand = (
commandArgs.push("--build-arg", arg); commandArgs.push("--build-arg", arg);
} }
for (const secret of secrets) {
const [key] = secret.split("=");
commandArgs.push("--secret", `id=${key},env=${key}`);
}
/* /*
Do not generate an environment file when publishDirectory is specified, Do not generate an environment file when publishDirectory is specified,
as it could be publicly exposed. as it could be publicly exposed.
@@ -105,6 +140,14 @@ export const getDockerCommand = (
); );
} }
// Export secrets as environment variables
if (secrets.length > 0) {
command += "\n# Export build secrets\n";
for (const secret of secrets) {
command += `export ${secret}\n`;
}
}
command += ` command += `
echo "Building ${appName}" >> ${logPath}; echo "Building ${appName}" >> ${logPath};
cd ${dockerContextPath} >> ${logPath} 2>> ${logPath} || { cd ${dockerContextPath} >> ${logPath} 2>> ${logPath} || {

View File

@@ -266,7 +266,7 @@ export const getGitlabRepositories = async (gitlabId?: string) => {
if (groupName) { if (groupName) {
return full_path.toLowerCase().includes(groupName) && kind === "group"; return full_path.toLowerCase().includes(groupName) && kind === "group";
} }
return kind === "member"; return kind === "user";
}); });
const mappedRepositories = filteredRepos.map((repo: any) => { const mappedRepositories = filteredRepos.map((repo: any) => {
return { return {
@@ -433,7 +433,7 @@ export const testGitlabConnection = async (
if (groupName) { if (groupName) {
return full_path.toLowerCase().includes(groupName) && kind === "group"; return full_path.toLowerCase().includes(groupName) && kind === "group";
} }
return kind === "member"; return kind === "user";
}); });
return filteredRepos.length; return filteredRepos.length;

View File

@@ -137,12 +137,44 @@ export const readRemoteConfig = async (serverId: string, appName: string) => {
} }
}; };
export const readMonitoringConfig = () => { export const readMonitoringConfig = (readAll = false) => {
const { DYNAMIC_TRAEFIK_PATH } = paths(); const { DYNAMIC_TRAEFIK_PATH } = paths();
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log"); const configPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log");
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const yamlStr = fs.readFileSync(configPath, "utf8"); if (!readAll) {
return yamlStr; // Read first 500 lines
let content = "";
let chunk = "";
let validCount = 0;
for (const char of fs.readFileSync(configPath, "utf8")) {
chunk += char;
if (char === "\n") {
try {
const trimmed = chunk.trim();
if (
trimmed !== "" &&
trimmed.startsWith("{") &&
trimmed.endsWith("}")
) {
const log = JSON.parse(trimmed);
if (log.ServiceName !== "dokploy-service-app@file") {
content += chunk;
validCount++;
if (validCount >= 500) {
break;
}
}
}
} catch {
// Ignore invalid JSON
}
chunk = "";
}
}
return content;
}
return fs.readFileSync(configPath, "utf8");
} }
return null; return null;
}; };

1
pnpm-lock.yaml generated
View File

@@ -6282,6 +6282,7 @@ packages:
oslo@1.2.0: oslo@1.2.0:
resolution: {integrity: sha512-OoFX6rDsNcOQVAD2gQD/z03u4vEjWZLzJtwkmgfRF+KpQUXwdgEXErD7zNhyowmHwHefP+PM9Pw13pgpHMRlzw==} resolution: {integrity: sha512-OoFX6rDsNcOQVAD2gQD/z03u4vEjWZLzJtwkmgfRF+KpQUXwdgEXErD7zNhyowmHwHefP+PM9Pw13pgpHMRlzw==}
deprecated: Package is no longer supported. Please see https://oslojs.dev for the successor project.
otpauth@9.3.4: otpauth@9.3.4:
resolution: {integrity: sha512-qXv+lpsCUO9ewitLYfeDKbLYt7UUCivnU/fwGK2OqhgrCBsRkTUNKWsgKAhkXG3aistOY+jEeuL90JEBu6W3mQ==} resolution: {integrity: sha512-qXv+lpsCUO9ewitLYfeDKbLYt7UUCivnU/fwGK2OqhgrCBsRkTUNKWsgKAhkXG3aistOY+jEeuL90JEBu6W3mQ==}