feat: add PhpMyAdmin as Database Tool

This commit is contained in:
biersoeckli
2025-01-29 11:52:34 +00:00
parent 3cdb6f218d
commit e7de118b03
9 changed files with 232 additions and 29 deletions

View File

@@ -2,6 +2,7 @@
import appService from "@/server/services/app.service";
import dbGateService from "@/server/services/db-tool-services/dbgate.service";
import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service";
import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils";
import { AppTemplateUtils } from "@/server/utils/app-template.utils";
import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info.model";
@@ -16,11 +17,18 @@ export const getDatabaseCredentials = async (appId: string) =>
return new SuccessActionResult(credentials);
}) as Promise<ServerActionResult<unknown, DatabaseTemplateInfoModel>>;
export const getIsDbGateActive = async (appId: string) =>
export const getIsDbToolActive = async (appId: string, dbTool: 'dbgate' | 'phpmyadmin') =>
simpleAction(async () => {
await getAuthUserSession();
const isActive = await dbGateService.isDbToolRunning(appId);
return new SuccessActionResult(isActive);
if (dbTool === 'dbgate') {
const isActive = await dbGateService.isDbToolRunning(appId);
return new SuccessActionResult(isActive);
} else if (dbTool === 'phpmyadmin') {
const isActive = await phpMyAdminService.isDbToolRunning(appId);
return new SuccessActionResult(isActive);
} else {
throw new ServiceException('Unknown db tool');
}
}) as Promise<ServerActionResult<unknown, boolean>>;
export const deployDbTool = async (appId: string, dbTool: 'dbgate' | 'phpmyadmin') =>
@@ -29,6 +37,9 @@ export const deployDbTool = async (appId: string, dbTool: 'dbgate' | 'phpmyadmin
if (dbTool === 'dbgate') {
await dbGateService.deploy(appId);
return new SuccessActionResult();
} else if (dbTool === 'phpmyadmin') {
await phpMyAdminService.deploy(appId);
return new SuccessActionResult();
} else {
throw new ServiceException('Unknown db tool');
}
@@ -39,6 +50,8 @@ export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool:
await getAuthUserSession();
if (dbTool === 'dbgate') {
return new SuccessActionResult(await dbGateService.getLoginCredentialsForRunningDbGate(appId));
} else if (dbTool === 'phpmyadmin') {
return new SuccessActionResult(await phpMyAdminService.getLoginCredentialsForRunningDbGate(appId));
} else {
throw new ServiceException('Unknown db tool');
}
@@ -50,6 +63,9 @@ export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool
if (dbTool === 'dbgate') {
await dbGateService.deleteToolForAppIfExists(appId);
return new SuccessActionResult();
} else if (dbTool === 'phpmyadmin') {
await phpMyAdminService.deleteToolForAppIfExists(appId);
return new SuccessActionResult();
} else {
throw new ServiceException('Unknown db tool');
}

View File

@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { Toast } from "@/frontend/utils/toast.utils";
import { Actions } from "@/frontend/utils/nextjs-actions.utils";
import { deleteDbToolDeploymentForAppIfExists, deployDbTool, downloadDbGateFilesForApp, getIsDbGateActive, getLoginCredentialsForRunningDbTool } from "./actions";
import { deleteDbToolDeploymentForAppIfExists, deployDbTool, downloadDbGateFilesForApp, getIsDbToolActive, getLoginCredentialsForRunningDbTool } from "./actions";
import { Label } from "@/components/ui/label";
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
import { Switch } from "@/components/ui/switch";
@@ -25,7 +25,7 @@ export default function DbGateDbTool({
const [loading, setLoading] = useState(false);
const loadIsDbGateActive = async (appId: string) => {
const response = await Actions.run(() => getIsDbGateActive(appId));
const response = await Actions.run(() => getIsDbToolActive(appId, 'dbgate'));
setIsDbGateActive(response);
}
@@ -80,9 +80,9 @@ export default function DbGateDbTool({
}, [app]);
return <>
{isDbGateActive === undefined ? <FullLoadingSpinner /> : <div className="flex gap-4 items-center">
<div className="flex gap-4 items-center">
<div className="flex items-center space-x-2">
<Switch id="canary-channel-mode" disabled={loading} checked={isDbGateActive} onCheckedChange={async (checked) => {
<Switch id="canary-channel-mode" disabled={loading || isDbGateActive === undefined} checked={isDbGateActive} onCheckedChange={async (checked) => {
try {
setLoading(true);
if (checked) {
@@ -99,7 +99,7 @@ export default function DbGateDbTool({
</div>
{isDbGateActive && <>
<Button variant='outline' onClick={() => openDbGateAsync()}
disabled={!isDbGateActive || loading}>Open DB Gate</Button>
disabled={loading}>Open DB Gate</Button>
<TooltipProvider>
<Tooltip delayDuration={300}>
@@ -113,7 +113,8 @@ export default function DbGateDbTool({
</Tooltip>
</TooltipProvider>
</>}
{loading && <LoadingSpinner></LoadingSpinner>}
</div>}
{(loading || isDbGateActive === undefined) && <LoadingSpinner></LoadingSpinner>}
</div>
</>;
}

View File

@@ -1,6 +1,7 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import DbGateDbTool from "./db-gate";
import DbGateDbTool from "./db-gate-db-tool";
import PhpMyAdminDbTool from "./phpmyadmin-db-tool";
export default function DbToolsCard({
app
@@ -14,8 +15,9 @@ export default function DbToolsCard({
<CardTitle>Database Access</CardTitle>
<CardDescription>Activate one of the following tools to access the database through your browser.</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<DbGateDbTool app={app} />
{['MYSQL', 'MARIADB'].includes(app.appType) && <PhpMyAdminDbTool app={app} />}
</CardContent>
</Card >
</>;

View File

@@ -0,0 +1,91 @@
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { Toast } from "@/frontend/utils/toast.utils";
import { Actions } from "@/frontend/utils/nextjs-actions.utils";
import { deleteDbToolDeploymentForAppIfExists, deployDbTool, getIsDbToolActive, getLoginCredentialsForRunningDbTool } from "./actions";
import { Label } from "@/components/ui/label";
import FullLoadingSpinner from "@/components/ui/full-loading-spinnter";
import { Switch } from "@/components/ui/switch";
import { Code } from "@/components/custom/code";
import LoadingSpinner from "@/components/ui/loading-spinner";
export default function PhpMyAdminDbTool({
app
}: {
app: AppExtendedModel;
}) {
const { openConfirmDialog } = useConfirmDialog();
const [isDbToolActive, setIsDbToolActive] = useState<boolean | undefined>(undefined);
const [loading, setLoading] = useState(false);
const loadIdDbToolActive = async (appId: string) => {
const response = await Actions.run(() => getIsDbToolActive(appId, 'phpmyadmin'));
setIsDbToolActive(response);
}
const openDbTool = async () => {
try {
setLoading(true);
const credentials = await Actions.run(() => getLoginCredentialsForRunningDbTool(app.id, 'phpmyadmin'));
setLoading(false);
await openConfirmDialog({
title: "Open DB Tool",
description: <>
PHP My Admin is ready and can be opened in a new tab. <br />
Use the following credentials to login:
<div className="pt-3 grid grid-cols-1 gap-1">
<Label>Username</Label>
<div> <Code>{credentials.username}</Code></div>
</div>
<div className="pt-3 pb-4 grid grid-cols-1 gap-1">
<Label>Password</Label>
<div><Code>{credentials.password}</Code></div>
</div>
<div>
<Button variant='outline' onClick={() => window.open(credentials.url, '_blank')}>Open PHP My Admin</Button>
</div>
</>,
okButton: '',
cancelButton: "Close"
});
} finally {
setLoading(false);
}
}
useEffect(() => {
loadIdDbToolActive(app.id);
return () => {
setIsDbToolActive(undefined);
}
}, [app]);
return <>
<div className="flex gap-4 items-center">
<div className="flex items-center space-x-2">
<Switch id="canary-channel-mode" disabled={loading || isDbToolActive === undefined} checked={isDbToolActive} onCheckedChange={async (checked) => {
try {
setLoading(true);
if (checked) {
await Toast.fromAction(() => deployDbTool(app.id, 'phpmyadmin'), 'PHP My Admin is now activated', 'activating PHP My Admin...');
} else {
await Toast.fromAction(() => deleteDbToolDeploymentForAppIfExists(app.id, 'phpmyadmin'), 'PHP My Admin has been deactivated', 'Deactivating PHP My Admin...');
}
await loadIdDbToolActive(app.id);
} finally {
setLoading(false);
}
}} />
<Label htmlFor="airplane-mode">PHP My Admin</Label>
</div>
{isDbToolActive && <>
<Button variant='outline' onClick={() => openDbTool()}
disabled={!isDbToolActive || loading}>Open PHP My Admin</Button>
</>}
{(loading || isDbToolActive === undefined) && <LoadingSpinner></LoadingSpinner>}
</div>
</>;
}

View File

@@ -38,26 +38,30 @@ export default function EnvEdit({ app }: {
<Card>
<CardHeader>
<CardTitle>Environment Variables</CardTitle>
<CardDescription>Provide optional environment variables for your application.</CardDescription>
<CardDescription>
Provide optional environment variables for your application.
{app.appType !== 'APP' && <div className="text-sm text-red-500 pt-2">You should not change ENV variables for databases.</div>}
</CardDescription>
</CardHeader>
<Form {...form}>
<form action={(e) => form.handleSubmit((data) => {
return formAction(data);
})()}>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="envVars"
render={({ field }) => (
<FormItem>
<FormLabel>Env Variables</FormLabel>
<FormControl>
<Textarea className="h-96" placeholder="NAME=VALUE..." {...field} value={field.value} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="envVars"
render={({ field }) => (
<FormItem>
<FormLabel>Env Variables</FormLabel>
<FormControl>
<Textarea className="h-96" placeholder="NAME=VALUE..." {...field} value={field.value} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter>
<SubmitButton>Save</SubmitButton>

View File

@@ -64,7 +64,7 @@ export class BaseDbToolService {
return { url: `https://${traefikHostname}`, username, password };
}
async deployToolForDatabase(appId: string, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment) {
async deployToolForDatabase(appId: string, appPort: number, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment) {
const app = await appService.getExtendedById(appId);
const toolAppName = this.appIdToToolNameConverter(appId);
@@ -84,7 +84,7 @@ export class BaseDbToolService {
await svcService.createOrUpdateService(namespace, toolAppName, [{
name: 'http',
port: 80,
targetPort: 3000,
targetPort: appPort,
}]);
console.log(`Creating ingress for DB Tool ${toolAppName} for app ${appId}`);

View File

@@ -56,7 +56,7 @@ class DbGateService extends BaseDbToolService {
}
async deploy(appId: string) {
await this.deployToolForDatabase(appId, (app) => {
await this.deployToolForDatabase(appId, 3000, (app) => {
const authPassword = randomBytes(15).toString('hex');
const dbGateAppName = KubeObjectNameUtils.toDbGateId(app.id);
const projectId = app.projectId;

View File

@@ -0,0 +1,85 @@
import { ServiceException } from "@/shared/model/service.exception.model";
import { KubeObjectNameUtils } from "../../utils/kube-object-name.utils";
import { randomBytes } from "crypto";
import { V1Deployment, V1EnvVar } from "@kubernetes/client-node";
import { Constants } from "@/shared/utils/constants";
import podService from "../pod.service";
import { AppTemplateUtils } from "../../utils/app-template.utils";
import appService from "../app.service";
import { PathUtils } from "../../utils/path.utils";
import { FsUtils } from "../../utils/fs.utils";
import path from "path";
import { BaseDbToolService } from "./base-db-tool.service";
class PhpMyAdminService extends BaseDbToolService {
constructor() {
super((app) => KubeObjectNameUtils.toPhpMyAdminId(app));
}
async getLoginCredentialsForRunningDbGate(appId: string) {
return await this.getLoginCredentialsForRunningTool(appId, (_, app) => {
const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app);
return { username: dbCredentials.username, password: dbCredentials.password };
});
}
async deploy(appId: string) {
await this.deployToolForDatabase(appId, 80, (app) => {
const appName = this.appIdToToolNameConverter(app.id);
const projectId = app.projectId;
const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app);
const body: V1Deployment = {
metadata: {
name: appName
},
spec: {
replicas: 1,
selector: {
matchLabels: {
app: appName
}
},
template: {
metadata: {
labels: {
app: appName
},
annotations: {
[Constants.QS_ANNOTATION_APP_ID]: app.id,
[Constants.QS_ANNOTATION_PROJECT_ID]: projectId,
deploymentTimestamp: new Date().getTime() + "",
"kubernetes.io/change-cause": `Deployment ${new Date().toISOString()}`
}
},
spec: {
containers: [
{
name: appName,
image: 'phpmyadmin/phpmyadmin:latest',
imagePullPolicy: 'Always',
env: [
{
name: 'PMA_PORT',
value: dbCredentials.port + ''
},
{
name: 'PMA_HOST',
value: dbCredentials.hostname
},
]
}
],
}
}
}
};
return body;
});
}
}
const phpMyAdminService = new PhpMyAdminService();
export default phpMyAdminService;

View File

@@ -72,4 +72,8 @@ export class KubeObjectNameUtils {
static toDbGateId(appId: string): `dbgate-${string}` {
return `dbgate-${appId}`;
}
static toPhpMyAdminId(appId: string): `phpma-${string}` {
return `phpma-${appId}`;
}
}