Enviroment
This commit is contained in:
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import testAuth from "../auth";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { getEnv, setEnv } from "@/core/server/application";
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const auth = await testAuth(req);
|
||||
|
||||
if (auth) {
|
||||
return NextResponse.json(
|
||||
{ message: auth },
|
||||
{ status: StatusCodes.UNAUTHORIZED }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await getEnv());
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const auth = await testAuth(req);
|
||||
|
||||
if (auth) {
|
||||
return NextResponse.json(
|
||||
{ message: auth },
|
||||
{ status: StatusCodes.UNAUTHORIZED }
|
||||
);
|
||||
}
|
||||
|
||||
const { appId, variables } = await req.json();
|
||||
|
||||
if (!appId || !variables) {
|
||||
return NextResponse.json(
|
||||
{ message: "Missing appId, variables in body" },
|
||||
{ status: StatusCodes.BAD_REQUEST }
|
||||
);
|
||||
}
|
||||
|
||||
await setEnv(appId, variables);
|
||||
|
||||
return NextResponse.json({ message: "ok", success: true });
|
||||
}
|
||||
@@ -6,18 +6,32 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Plus, Trash2, Eye, EyeOff, Copy, Save, Lock } from "lucide-react";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
Save,
|
||||
Lock,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Env } from "@/lib/types";
|
||||
import { getEnv } from "@/core/application";
|
||||
import { getEnv, setEnv } from "@/core/application";
|
||||
import { AddVariableDialog } from "@/components/dialogs/AddVariable";
|
||||
|
||||
export default function EnvironmentPage() {
|
||||
const [selectedApp, setSelectedApp] = useState("");
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const [envGroups, setEnvGroups] = useState<Record<string, Env[]>>({});
|
||||
const [envGroups, setEnvGroups] = useState<Record<string, Env>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
setEnvGroups(await getEnv());
|
||||
const env = await getEnv();
|
||||
setEnvGroups(env);
|
||||
const vars = Object.keys(env);
|
||||
if (vars.length > 0) setSelectedApp(vars[0]);
|
||||
};
|
||||
fetch();
|
||||
}, []);
|
||||
@@ -32,6 +46,17 @@ export default function EnvironmentPage() {
|
||||
return "•".repeat(Math.min(value.length, 24));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await setEnv(selectedApp, currentGroup.variables);
|
||||
} catch (error) {
|
||||
console.error("Failed to save:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
title="Environment"
|
||||
@@ -47,21 +72,23 @@ export default function EnvironmentPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-2 flex gap-2 md:block overflow-x-auto">
|
||||
{Object.entries(envGroups).map(([key, variables]) => (
|
||||
{Object.entries(envGroups).map(([id, variables]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedApp(key)}
|
||||
key={id}
|
||||
onClick={() => setSelectedApp(id)}
|
||||
className={`
|
||||
whitespace-nowrap md:w-full text-left px-3 py-2 text-sm font-mono transition-colors
|
||||
${
|
||||
selectedApp === key
|
||||
selectedApp === id
|
||||
? "bg-secondary text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{key}
|
||||
<Badge className="ml-2 text-[10px]">{variables.length}</Badge>
|
||||
{variables.name}
|
||||
<Badge className="ml-2 text-[10px]">
|
||||
{variables.variables.length}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
@@ -75,19 +102,32 @@ export default function EnvironmentPage() {
|
||||
<div>
|
||||
<CardTitle className="font-mono">{selectedApp}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{currentGroup?.length} variables •{" "}
|
||||
{currentGroup?.filter((v) => v.secret).length} secrets
|
||||
{currentGroup?.variables.length} variables •{" "}
|
||||
{currentGroup?.variables.filter((v) => v.secret).length}{" "}
|
||||
secrets
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Add Variable</span>
|
||||
</Button>
|
||||
<Button size="sm" className="gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Save Changes</span>
|
||||
<AddVariableDialog
|
||||
selectedApp={selectedApp}
|
||||
currentGroup={currentGroup}
|
||||
setEnvGroups={setEnvGroups}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -102,7 +142,7 @@ export default function EnvironmentPage() {
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y divide-border md:divide-none">
|
||||
{currentGroup?.map((variable) => (
|
||||
{currentGroup?.variables.map((variable) => (
|
||||
<div
|
||||
key={variable.key}
|
||||
className="
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Env, EnvVariable } from "@/lib/types";
|
||||
|
||||
interface Props {
|
||||
selectedApp: string;
|
||||
currentGroup: Env;
|
||||
setEnvGroups: React.Dispatch<React.SetStateAction<Record<string, Env>>>;
|
||||
}
|
||||
|
||||
export function AddVariableDialog({
|
||||
selectedApp,
|
||||
currentGroup,
|
||||
setEnvGroups,
|
||||
}: Props) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [key, setKey] = React.useState("");
|
||||
const [value, setValue] = React.useState("");
|
||||
const [secret, setSecret] = React.useState(false);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!key) return;
|
||||
const newVariable: EnvVariable = { key, value, secret };
|
||||
setEnvGroups((env) => ({
|
||||
...env,
|
||||
[selectedApp]: {
|
||||
...currentGroup,
|
||||
variables: [...currentGroup.variables, newVariable],
|
||||
},
|
||||
}));
|
||||
|
||||
setKey("");
|
||||
setValue("");
|
||||
setSecret(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Add Variable</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Environment Variable</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the variable name, value, and whether it should be secret.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-1">
|
||||
<label className="text-sm font-medium">Variable Name</label>
|
||||
<Input
|
||||
placeholder="PORT"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<label className="text-sm font-medium">Value</label>
|
||||
<Input
|
||||
placeholder="3000"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="secret"
|
||||
checked={secret}
|
||||
onCheckedChange={(checked) => setSecret(!!checked)}
|
||||
/>
|
||||
<label htmlFor="secret" className="text-sm font-medium">
|
||||
Secret
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleAdd}>Add Variable</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
+18
-4
@@ -3,7 +3,7 @@
|
||||
import { verifySession } from "./auth/session";
|
||||
import { getNodes } from "./node";
|
||||
import axios from "axios";
|
||||
import { Env, ImageApp } from "@/lib/types";
|
||||
import { Env, EnvVariable, ImageApp } from "@/lib/types";
|
||||
|
||||
export async function deployNewApp(name: string, repo: string, nodeId: string) {
|
||||
if (await verifySession()) {
|
||||
@@ -42,7 +42,7 @@ export async function getApplications(): Promise<ImageApp[]> {
|
||||
).flat();
|
||||
}
|
||||
|
||||
export async function getEnv(): Promise<Record<string, Env[]>> {
|
||||
export async function getEnv(): Promise<Record<string, Env>> {
|
||||
const nodes = await getNodes();
|
||||
|
||||
const envRecords = await Promise.all(
|
||||
@@ -50,11 +50,25 @@ export async function getEnv(): Promise<Record<string, Env[]>> {
|
||||
const response = await axios.get(`http://${n.ip}:3000/api/env`, {
|
||||
headers: { Authorization: `ApiKey ${n.key}` },
|
||||
});
|
||||
return response.data as Record<string, Env[]>;
|
||||
return response.data as Record<string, Env>;
|
||||
})
|
||||
);
|
||||
|
||||
const merged: Record<string, Env[]> = Object.assign({}, ...envRecords);
|
||||
const merged: Record<string, Env> = Object.assign({}, ...envRecords);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function setEnv(appId: string, variables: EnvVariable[]) {
|
||||
const nodes = await getNodes();
|
||||
|
||||
nodes.forEach(async (n) => {
|
||||
await axios.post(
|
||||
`http://${n.ip}:3000/api/env`,
|
||||
{ appId, variables },
|
||||
{
|
||||
headers: { Authorization: `ApiKey ${n.key}` },
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { exec } from "child_process";
|
||||
import util from "util";
|
||||
import { Env, ImageApp } from "@/lib/types";
|
||||
import { Env, EnvVariable, ImageApp } from "@/lib/types";
|
||||
import { CPUusage } from "@/lib/server/calc";
|
||||
|
||||
const docker = new Docker();
|
||||
@@ -135,7 +135,7 @@ export async function deployNewApp(name: string, repo: string, nodeId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEnv(): Promise<Record<string, Env[]>> {
|
||||
export async function getEnv(): Promise<Record<string, Env>> {
|
||||
const apps = fs.readdirSync(DATA_DIR);
|
||||
|
||||
return apps.reduce((acc, appId) => {
|
||||
@@ -144,9 +144,23 @@ export async function getEnv(): Promise<Record<string, Env[]>> {
|
||||
|
||||
const envPath = path.join(DATA_DIR, appId, "env.json");
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envData: Env[] = JSON.parse(fs.readFileSync(envPath, "utf-8"));
|
||||
acc[name] = envData;
|
||||
const envData: EnvVariable[] = JSON.parse(
|
||||
fs.readFileSync(envPath, "utf-8")
|
||||
);
|
||||
acc[appId] = { variables: envData, id: appId, name };
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, Env[]>);
|
||||
}, {} as Record<string, Env>);
|
||||
}
|
||||
|
||||
export async function setEnv(appId: string, variables: EnvVariable[]) {
|
||||
const apps = fs.readdirSync(DATA_DIR);
|
||||
|
||||
apps.forEach((id) => {
|
||||
if (id === appId) {
|
||||
const envPath = path.join(DATA_DIR, appId, "env.json");
|
||||
|
||||
fs.writeFileSync(envPath, JSON.stringify(variables));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+7
-1
@@ -82,8 +82,14 @@ export interface Log {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
export interface EnvVariable {
|
||||
key: string;
|
||||
value: string;
|
||||
secret: boolean;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
id: string;
|
||||
name: string;
|
||||
variables: EnvVariable[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user