Enviroment

This commit is contained in:
2026-01-01 07:10:27 +01:00
parent a574aeb48f
commit f9f808f327
9 changed files with 386 additions and 30 deletions
+102
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15", "@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-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15", "@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": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "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": { "node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
+1
View File
@@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15", "@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-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
+41
View File
@@ -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 });
}
+60 -20
View File
@@ -6,18 +6,32 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; 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 { 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() { export default function EnvironmentPage() {
const [selectedApp, setSelectedApp] = useState(""); const [selectedApp, setSelectedApp] = useState("");
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({}); 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(() => { useEffect(() => {
const fetch = async () => { 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(); fetch();
}, []); }, []);
@@ -32,6 +46,17 @@ export default function EnvironmentPage() {
return "•".repeat(Math.min(value.length, 24)); 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 ( return (
<DashboardLayout <DashboardLayout
title="Environment" title="Environment"
@@ -47,21 +72,23 @@ export default function EnvironmentPage() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-2 flex gap-2 md:block overflow-x-auto"> <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 <button
key={key} key={id}
onClick={() => setSelectedApp(key)} onClick={() => setSelectedApp(id)}
className={` className={`
whitespace-nowrap md:w-full text-left px-3 py-2 text-sm font-mono transition-colors 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" ? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
} }
`} `}
> >
{key} {variables.name}
<Badge className="ml-2 text-[10px]">{variables.length}</Badge> <Badge className="ml-2 text-[10px]">
{variables.variables.length}
</Badge>
</button> </button>
))} ))}
</CardContent> </CardContent>
@@ -75,19 +102,32 @@ export default function EnvironmentPage() {
<div> <div>
<CardTitle className="font-mono">{selectedApp}</CardTitle> <CardTitle className="font-mono">{selectedApp}</CardTitle>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{currentGroup?.length} variables •{" "} {currentGroup?.variables.length} variables •{" "}
{currentGroup?.filter((v) => v.secret).length} secrets {currentGroup?.variables.filter((v) => v.secret).length}{" "}
secrets
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="gap-2"> <AddVariableDialog
<Plus className="w-4 h-4" /> selectedApp={selectedApp}
<span className="hidden sm:inline">Add Variable</span> currentGroup={currentGroup}
</Button> setEnvGroups={setEnvGroups}
<Button size="sm" className="gap-2"> />
<Save className="w-4 h-4" /> <Button
<span className="hidden sm:inline">Save Changes</span> 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> </Button>
</div> </div>
</CardHeader> </CardHeader>
@@ -102,7 +142,7 @@ export default function EnvironmentPage() {
{/* Rows */} {/* Rows */}
<div className="divide-y divide-border md:divide-none"> <div className="divide-y divide-border md:divide-none">
{currentGroup?.map((variable) => ( {currentGroup?.variables.map((variable) => (
<div <div
key={variable.key} key={variable.key}
className=" className="
+106
View File
@@ -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>
);
}
+32
View File
@@ -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
View File
@@ -3,7 +3,7 @@
import { verifySession } from "./auth/session"; import { verifySession } from "./auth/session";
import { getNodes } from "./node"; import { getNodes } from "./node";
import axios from "axios"; 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) { export async function deployNewApp(name: string, repo: string, nodeId: string) {
if (await verifySession()) { if (await verifySession()) {
@@ -42,7 +42,7 @@ export async function getApplications(): Promise<ImageApp[]> {
).flat(); ).flat();
} }
export async function getEnv(): Promise<Record<string, Env[]>> { export async function getEnv(): Promise<Record<string, Env>> {
const nodes = await getNodes(); const nodes = await getNodes();
const envRecords = await Promise.all( 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`, { const response = await axios.get(`http://${n.ip}:3000/api/env`, {
headers: { Authorization: `ApiKey ${n.key}` }, 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; 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}` },
}
);
});
}
+19 -5
View File
@@ -6,7 +6,7 @@ import fs from "fs";
import path from "path"; import path from "path";
import { exec } from "child_process"; import { exec } from "child_process";
import util from "util"; import util from "util";
import { Env, ImageApp } from "@/lib/types"; import { Env, EnvVariable, ImageApp } from "@/lib/types";
import { CPUusage } from "@/lib/server/calc"; import { CPUusage } from "@/lib/server/calc";
const docker = new Docker(); 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); const apps = fs.readdirSync(DATA_DIR);
return apps.reduce((acc, appId) => { 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"); const envPath = path.join(DATA_DIR, appId, "env.json");
if (fs.existsSync(envPath)) { if (fs.existsSync(envPath)) {
const envData: Env[] = JSON.parse(fs.readFileSync(envPath, "utf-8")); const envData: EnvVariable[] = JSON.parse(
acc[name] = envData; fs.readFileSync(envPath, "utf-8")
);
acc[appId] = { variables: envData, id: appId, name };
} }
return acc; 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
View File
@@ -82,8 +82,14 @@ export interface Log {
message: string; message: string;
} }
export interface Env { export interface EnvVariable {
key: string; key: string;
value: string; value: string;
secret: boolean; secret: boolean;
} }
export interface Env {
id: string;
name: string;
variables: EnvVariable[];
}