diff --git a/package-lock.json b/package-lock.json index 2dc78fe..8bd7982 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", @@ -1097,6 +1098,92 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -1942,6 +2029,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", diff --git a/package.json b/package.json index 59f20b5..f872aa2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", diff --git a/src/app/api/env/route.ts b/src/app/api/env/route.ts new file mode 100644 index 0000000..249ce8b --- /dev/null +++ b/src/app/api/env/route.ts @@ -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 { + 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 { + 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 }); +} diff --git a/src/app/environment/page.tsx b/src/app/environment/page.tsx index c5013b3..2f251c9 100644 --- a/src/app/environment/page.tsx +++ b/src/app/environment/page.tsx @@ -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>({}); - const [envGroups, setEnvGroups] = useState>({}); + const [envGroups, setEnvGroups] = useState>({}); + 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 ( - {Object.entries(envGroups).map(([key, variables]) => ( + {Object.entries(envGroups).map(([id, variables]) => ( ))} @@ -75,19 +102,32 @@ export default function EnvironmentPage() {
{selectedApp}

- {currentGroup?.length} variables •{" "} - {currentGroup?.filter((v) => v.secret).length} secrets + {currentGroup?.variables.length} variables •{" "} + {currentGroup?.variables.filter((v) => v.secret).length}{" "} + secrets

- -
@@ -102,7 +142,7 @@ export default function EnvironmentPage() { {/* Rows */}
- {currentGroup?.map((variable) => ( + {currentGroup?.variables.map((variable) => (
>>; +} + +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 ( + + + + + + + + Add Environment Variable + + Enter the variable name, value, and whether it should be secret. + + + +
+
+ + setKey(e.target.value)} + /> +
+ +
+ + setValue(e.target.value)} + /> +
+ +
+ setSecret(!!checked)} + /> + +
+
+ + + + +
+
+ ); +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..cb0b07b --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -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) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/src/core/application.ts b/src/core/application.ts index 46e3af6..4355640 100644 --- a/src/core/application.ts +++ b/src/core/application.ts @@ -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 { ).flat(); } -export async function getEnv(): Promise> { +export async function getEnv(): Promise> { const nodes = await getNodes(); const envRecords = await Promise.all( @@ -50,11 +50,25 @@ export async function getEnv(): Promise> { const response = await axios.get(`http://${n.ip}:3000/api/env`, { headers: { Authorization: `ApiKey ${n.key}` }, }); - return response.data as Record; + return response.data as Record; }) ); - const merged: Record = Object.assign({}, ...envRecords); + const merged: Record = 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}` }, + } + ); + }); +} diff --git a/src/core/server/application.ts b/src/core/server/application.ts index 4c3bd73..4954897 100644 --- a/src/core/server/application.ts +++ b/src/core/server/application.ts @@ -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> { +export async function getEnv(): Promise> { const apps = fs.readdirSync(DATA_DIR); return apps.reduce((acc, appId) => { @@ -144,9 +144,23 @@ export async function getEnv(): Promise> { 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); + }, {} as Record); +} + +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)); + } + }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 0244f1e..50ff4f4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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[]; +}