Re structured lib and core
This commit is contained in:
@@ -1,11 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import testAuth from "../auth";
|
import testAuth from "../auth";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import {
|
import { installApp, getApplications, removeApp } from "@/core/application";
|
||||||
installApp,
|
|
||||||
getApplications,
|
|
||||||
removeApp,
|
|
||||||
} from "@/core/server/application";
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||||
const auth = await testAuth(req);
|
const auth = await testAuth(req);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import testAuth from "../auth";
|
import testAuth from "../auth";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import { getDocker, removeContainer } from "@/core/server/docker";
|
import { getDocker, removeContainer } from "@/core/docker";
|
||||||
import { Docker } from "@/lib/types";
|
import { Docker } from "@/lib/types";
|
||||||
|
|
||||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import testAuth from "../../auth";
|
import testAuth from "../../auth";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import { startContainer, stopContainer } from "@/core/server/docker";
|
import { startContainer, stopContainer } from "@/core/docker";
|
||||||
|
|
||||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
const auth = await testAuth(req);
|
const auth = await testAuth(req);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import testAuth from "../../auth";
|
import testAuth from "../../auth";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import { getContainerStats } from "@/core/server/docker";
|
import { getContainerStats } from "@/core/docker";
|
||||||
|
|
||||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||||
const auth = await testAuth(req);
|
const auth = await testAuth(req);
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import testAuth from "../auth";
|
import testAuth from "../auth";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import { getEnv, setEnv } from "@/core/server/application";
|
import { getEnv, setEnv } from "@/core/application";
|
||||||
|
|
||||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||||
const auth = await testAuth(req);
|
const auth = await testAuth(req);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import testAuth from "../auth";
|
import testAuth from "../auth";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import { getAllContainerLogs } from "@/core/server/docker";
|
import { getAllContainerLogs } from "@/core/docker";
|
||||||
|
|
||||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||||
const auth = await testAuth(req);
|
const auth = await testAuth(req);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { MoreVertical, Plus, Rocket } from "lucide-react";
|
import { MoreVertical, Plus, Rocket } from "lucide-react";
|
||||||
import InstallApplicationDialog from "@/components/dialogs/InstallApplication";
|
import InstallApplicationDialog from "@/components/dialogs/InstallApplication";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getApplications, removeApp } from "@/core/application";
|
import { getApplications, removeApp } from "@/lib/dockship/application";
|
||||||
import RemoveDialog from "@/components/dialogs/Remove";
|
import RemoveDialog from "@/components/dialogs/Remove";
|
||||||
import { Application } from "@/lib/types";
|
import { Application } from "@/lib/types";
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Application, Deployment } from "@/lib/types";
|
import { Application, Deployment } from "@/lib/types";
|
||||||
import { getDeployments } from "@/core/server/deployment";
|
import { getDeployments } from "@/core/deployment";
|
||||||
import { getApplications } from "@/core/application";
|
import { getApplications } from "@/lib/dockship/application";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function DeploymentsPage() {
|
export default function DeploymentsPage() {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Trash2, Eye, EyeOff, Copy, Save, Lock, Loader2 } from "lucide-react";
|
import { Trash2, Eye, EyeOff, Copy, Save, Lock, Loader2 } from "lucide-react";
|
||||||
import { Env } from "@/lib/types";
|
import { Env } from "@/lib/types";
|
||||||
import { getEnv, setEnv } from "@/core/application";
|
import { getEnv, setEnv } from "@/lib/dockship/application";
|
||||||
import { AddVariableDialog } from "@/components/dialogs/AddVariable";
|
import { AddVariableDialog } from "@/components/dialogs/AddVariable";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Pause, Play, Download, Filter, RefreshCw } from "lucide-react";
|
import { Pause, Play, Download, Filter, RefreshCw } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Log } from "@/lib/types";
|
import { Log } from "@/lib/types";
|
||||||
import { getAllContainerLogs } from "@/core/docker";
|
import { getAllContainerLogs } from "@/lib/dockship/docker";
|
||||||
|
|
||||||
export default function LogsPage() {
|
export default function LogsPage() {
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { Progress } from "@/components/ui/progress";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { type ChartConfig } from "@/components/ui/chart";
|
import { type ChartConfig } from "@/components/ui/chart";
|
||||||
import GradientAreaChart from "@/components/GradientAreaChart";
|
import GradientAreaChart from "@/components/GradientAreaChart";
|
||||||
import { getMetrics } from "@/core/metrics";
|
import { getMetrics } from "@/lib/dockship/metrics";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import RadialChart from "@/components/RadialChart";
|
import RadialChart from "@/components/RadialChart";
|
||||||
import { getLiveNodes, getNodes } from "@/core/node";
|
import { getLiveNodes, getNodes } from "@/lib/dockship/node";
|
||||||
import { average } from "@/lib/format";
|
import { average } from "@/lib/format";
|
||||||
import { getContainerStats, getDocker } from "@/core/docker";
|
import { getContainerStats, getDocker } from "@/lib/dockship/docker";
|
||||||
import { SimpleStats } from "@/lib/types";
|
import { SimpleStats } from "@/lib/types";
|
||||||
|
|
||||||
const cpuChartConfig = {
|
const cpuChartConfig = {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { Server, Plus, Settings } from "lucide-react";
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Docker, Node, NodeLiveData } from "@/lib/types";
|
import { Docker, Node, NodeLiveData } from "@/lib/types";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getLiveNodes } from "@/core/node";
|
import { getLiveNodes } from "@/lib/dockship/node";
|
||||||
import { formatBytes } from "@/lib/format";
|
import { formatBytes } from "@/lib/format";
|
||||||
import { setNodes as updateNodes } from "@/core/node";
|
import { setNodes as updateNodes } from "@/lib/dockship/node";
|
||||||
import AddNode from "@/components/dialogs/AddNode";
|
import AddNode from "@/components/dialogs/AddNode";
|
||||||
import { getDocker } from "@/core/docker";
|
import { getDocker } from "@/lib/dockship/docker";
|
||||||
import RemoveDialog from "@/components/dialogs/Remove";
|
import RemoveDialog from "@/components/dialogs/Remove";
|
||||||
|
|
||||||
function DiskUsage({ liveData }: NodeLiveData) {
|
function DiskUsage({ liveData }: NodeLiveData) {
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getLiveNodes } from "@/core/node";
|
import { getLiveNodes } from "@/lib/dockship/node";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Application, Docker, Node, NodeLiveData } from "@/lib/types";
|
import { Application, Docker, Node, NodeLiveData } from "@/lib/types";
|
||||||
import { getDocker } from "@/core/docker";
|
import { getDocker } from "@/lib/dockship/docker";
|
||||||
import { getApplications } from "@/core/application";
|
import { getApplications } from "@/lib/dockship/application";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const recentDeployments = [
|
const recentDeployments = [
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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 redirectAuth from "@/core/auth/redirect";
|
import redirectAuth from "@/lib/dockship/redirect";
|
||||||
import { GithubIcon } from "lucide-react";
|
import { GithubIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
authNode,
|
authNode,
|
||||||
getNodes,
|
getNodes,
|
||||||
setNodes as updateNodes,
|
setNodes as updateNodes,
|
||||||
} from "@/core/node";
|
} from "@/lib/dockship/node";
|
||||||
|
|
||||||
export default function AddNode({ children }: { children: ReactNode }) {
|
export default function AddNode({ children }: { children: ReactNode }) {
|
||||||
const [step, setStep] = useState<1 | 2>(1);
|
const [step, setStep] = useState<1 | 2>(1);
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { getLiveNodes } from "@/core/node";
|
import { getLiveNodes } from "@/lib/dockship/node";
|
||||||
import { Combobox } from "../ui/combobox";
|
import { Combobox } from "../ui/combobox";
|
||||||
import { Node, NodeLiveData } from "@/lib/types";
|
import { Node, NodeLiveData } from "@/lib/types";
|
||||||
import { installApp } from "@/core/application";
|
import { installApp } from "@/lib/dockship/application";
|
||||||
|
|
||||||
export default function InstallApplicationDialog({
|
export default function InstallApplicationDialog({
|
||||||
children,
|
children,
|
||||||
|
|||||||
+105
-65
@@ -1,88 +1,128 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { verifySession } from "./auth/session";
|
import Docker from "dockerode";
|
||||||
import { getNodes } from "./node";
|
import { randomUUID } from "crypto";
|
||||||
import axios from "axios";
|
import fs from "fs";
|
||||||
import { Env, EnvVariable, Application } from "@/lib/types";
|
import path from "path";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import util from "util";
|
||||||
|
import { Application, Env, EnvVariable } from "@/lib/types";
|
||||||
|
|
||||||
export async function installApp(name: string, repo: string, nodeId: string) {
|
const docker = new Docker();
|
||||||
if (await verifySession()) {
|
const execAsync = util.promisify(exec);
|
||||||
throw new Error("Unauthorized");
|
const DATA_DIR = path.join(process.cwd(), "data", "apps");
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = await getNodes();
|
|
||||||
|
|
||||||
const targetNode = nodes.find((node) => node.node_id === nodeId);
|
|
||||||
|
|
||||||
return await (
|
|
||||||
await axios.post(
|
|
||||||
`http://${targetNode?.ip}:3000/api/applications`,
|
|
||||||
{ name, repo, nodeId },
|
|
||||||
{
|
|
||||||
headers: { Authorization: `ApiKey ${targetNode?.key}` },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).data.applications;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getApplications(): Promise<Application[]> {
|
export async function getApplications(): Promise<Application[]> {
|
||||||
const nodes = await getNodes();
|
return fs
|
||||||
|
.readdirSync(DATA_DIR)
|
||||||
|
.map((appId) =>
|
||||||
|
JSON.parse(
|
||||||
|
fs.readFileSync(path.join(DATA_DIR, appId, "app.json")).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
export async function installApp(name: string, repo: string, nodeId: string) {
|
||||||
await Promise.all(
|
// Create app ID
|
||||||
nodes.map(
|
const appId = `app_${randomUUID()}`;
|
||||||
async (n) =>
|
const appDir = path.join(DATA_DIR, appId);
|
||||||
await (
|
const repoDir = path.join(appDir, "repo");
|
||||||
await axios.get(`http://${n.ip}:3000/api/applications`, {
|
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
// Create directories
|
||||||
})
|
fs.mkdirSync(repoDir, { recursive: true });
|
||||||
).data.applications
|
|
||||||
)
|
// Write app.json
|
||||||
)
|
const appConfig: Application = {
|
||||||
).flat();
|
id: appId,
|
||||||
|
name,
|
||||||
|
repo,
|
||||||
|
nodeId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
deployments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(appDir, "app.json"),
|
||||||
|
JSON.stringify(appConfig, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write env.json
|
||||||
|
fs.writeFileSync(path.join(appDir, "env.json"), JSON.stringify([], null, 2));
|
||||||
|
|
||||||
|
// Clone repository
|
||||||
|
try {
|
||||||
|
await execAsync(`git clone ${repo} ${repoDir}`);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error("Failed to clone repository");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Dockerfile
|
||||||
|
const dockerfilePath = path.join(repoDir, "Dockerfile");
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.accessSync(dockerfilePath);
|
||||||
|
} catch {
|
||||||
|
fs.rmSync(appDir, { recursive: true });
|
||||||
|
throw new Error("Dockerfile not found in repository root");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
appId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnv(): Promise<Record<string, Env>> {
|
export async function getEnv(): Promise<Record<string, Env>> {
|
||||||
const nodes = await getNodes();
|
const apps = fs.readdirSync(DATA_DIR);
|
||||||
|
|
||||||
const envRecords = await Promise.all(
|
return apps.reduce((acc, appId) => {
|
||||||
nodes.map(async (n) => {
|
const appPath = path.join(DATA_DIR, appId, "app.json");
|
||||||
const response = await axios.get(`http://${n.ip}:3000/api/env`, {
|
const name = JSON.parse(fs.readFileSync(appPath).toString()).name;
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
|
||||||
});
|
const envPath = path.join(DATA_DIR, appId, "env.json");
|
||||||
return response.data as Record<string, Env>;
|
if (fs.existsSync(envPath)) {
|
||||||
})
|
const envData: Record<string, EnvVariable> = JSON.parse(
|
||||||
|
fs.readFileSync(envPath, "utf-8")
|
||||||
);
|
);
|
||||||
|
acc[appId] = { variables: envData, id: appId, name };
|
||||||
const merged: Record<string, Env> = Object.assign({}, ...envRecords);
|
}
|
||||||
|
return acc;
|
||||||
return merged;
|
}, {} as Record<string, Env>);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setEnv(
|
export async function setEnv(appId: string, variables: EnvVariable[]) {
|
||||||
appId: string,
|
const apps = fs.readdirSync(DATA_DIR);
|
||||||
variables: Record<string, EnvVariable>
|
|
||||||
) {
|
|
||||||
const nodes = await getNodes();
|
|
||||||
|
|
||||||
nodes.forEach(async (n) => {
|
apps.forEach((id) => {
|
||||||
await axios.post(
|
if (id === appId) {
|
||||||
`http://${n.ip}:3000/api/env`,
|
const envPath = path.join(DATA_DIR, appId, "env.json");
|
||||||
{ appId, variables },
|
|
||||||
{
|
fs.writeFileSync(envPath, JSON.stringify(variables));
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeApp(appId: string) {
|
export async function removeApp(appId: string) {
|
||||||
const nodes = await getNodes();
|
const appPath = path.join(DATA_DIR, appId);
|
||||||
|
|
||||||
nodes.forEach(async (n) => {
|
const appMeta: Application = JSON.parse(
|
||||||
await axios.delete(`http://${n.ip}:3000/api/applications`, {
|
fs.readFileSync(path.join(appPath, "app.json")).toString()
|
||||||
params: { appId },
|
);
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
|
||||||
|
appMeta.deployments.forEach(async (imgId) => {
|
||||||
|
// Remove containers
|
||||||
|
(await docker.listContainers({ all: true }))
|
||||||
|
.filter((con) => con.ImageID === imgId)
|
||||||
|
.forEach((con) => {
|
||||||
|
const container = docker.getContainer(con.Id);
|
||||||
|
container.stop();
|
||||||
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove image
|
||||||
|
const image = docker.getImage(imgId);
|
||||||
|
image.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fs.rmSync(appPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-84
@@ -1,110 +1,112 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import axios from "axios";
|
import { CPUusage } from "@/lib/server/calc";
|
||||||
import { Deployment, Docker, Log, Node, SimpleStats } from "@/lib/types";
|
import { Log, SimpleStats } from "@/lib/types";
|
||||||
import { verifySession } from "./auth/session";
|
import Docker from "dockerode";
|
||||||
import { getNodes } from "./node";
|
|
||||||
|
|
||||||
export async function getDocker(nodes: Node[]): Promise<Docker[]> {
|
const docker = new Docker();
|
||||||
if (await verifySession()) return [];
|
|
||||||
|
|
||||||
return await Promise.all(
|
export async function getDocker() {
|
||||||
nodes.map(async (node) => {
|
const version = await docker.version();
|
||||||
const data = await (
|
const containers = await docker.listContainers({ all: true });
|
||||||
await axios.get(`http://${node.ip}:3000/api/container/`, {
|
return { version, containers };
|
||||||
headers: { Authorization: `ApiKey ${node.key}` },
|
|
||||||
})
|
|
||||||
).data;
|
|
||||||
|
|
||||||
return { ...node, containers: data.containers, version: data.version };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContainerStats(
|
export async function getContainerStats(
|
||||||
containerId: string
|
containerId: string
|
||||||
): Promise<SimpleStats | undefined> {
|
): Promise<SimpleStats | undefined> {
|
||||||
const nodes = await getNodes();
|
const container = docker.getContainer(containerId);
|
||||||
|
|
||||||
return (
|
const data = await container.inspect();
|
||||||
await Promise.all(
|
|
||||||
nodes.map(
|
if (!data.State.Running) {
|
||||||
async (n) =>
|
return;
|
||||||
await (
|
}
|
||||||
await axios.get(`http://${n.ip}:3000/api/container/stats`, {
|
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
const stats = await container.stats({ stream: false });
|
||||||
params: { containerId },
|
|
||||||
})
|
return {
|
||||||
).data?.stats
|
cpu: CPUusage(stats),
|
||||||
)
|
memory: (stats.memory_stats.usage / stats.memory_stats.limit) * 100,
|
||||||
)
|
};
|
||||||
).find((s) => s);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startContainer(containerId: string) {
|
export async function startContainer(containerId: string) {
|
||||||
const nodes = await getNodes();
|
await docker.getContainer(containerId).start();
|
||||||
|
|
||||||
nodes.forEach(
|
|
||||||
async (n) =>
|
|
||||||
await (
|
|
||||||
await axios.post(
|
|
||||||
`http://${n.ip}:3000/api/container/start`,
|
|
||||||
{ containerId },
|
|
||||||
{
|
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
|
||||||
params: { containerId },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).data
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function stopContainer(containerId: string) {
|
export async function stopContainer(containerId: string) {
|
||||||
const nodes = await getNodes();
|
await docker.getContainer(containerId).stop();
|
||||||
|
|
||||||
nodes.forEach(
|
|
||||||
async (n) =>
|
|
||||||
await (
|
|
||||||
await axios.delete(`http://${n.ip}:3000/api/container/start`, {
|
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
|
||||||
params: { containerId },
|
|
||||||
})
|
|
||||||
).data
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeContainer(containerId: string) {
|
export async function removeContainer(containerId: string) {
|
||||||
const nodes = await getNodes();
|
await docker.getContainer(containerId).remove();
|
||||||
|
}
|
||||||
|
|
||||||
nodes.forEach(
|
function inferLevel(message: string): "info" | "warn" | "error" {
|
||||||
async (n) =>
|
const msg = message.toLowerCase();
|
||||||
await (
|
|
||||||
await axios.delete(`http://${n.ip}:3000/api/container`, {
|
if (msg.includes("error") || msg.includes("fatal")) return "error";
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
if (msg.includes("warn")) return "warn";
|
||||||
params: { containerId },
|
|
||||||
})
|
return "info";
|
||||||
).data
|
}
|
||||||
);
|
|
||||||
|
function demuxLogs(buffer: Buffer): string {
|
||||||
|
let offset = 0;
|
||||||
|
let output = "";
|
||||||
|
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
const streamType = buffer[offset];
|
||||||
|
const length = buffer.readUInt32BE(offset + 4);
|
||||||
|
offset += 8;
|
||||||
|
|
||||||
|
output += buffer.slice(offset, offset + length).toString("utf8");
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContainerLogs(
|
||||||
|
containerId: string,
|
||||||
|
name: string
|
||||||
|
): Promise<Log[]> {
|
||||||
|
const container = docker.getContainer(containerId);
|
||||||
|
|
||||||
|
const raw = await container.logs({
|
||||||
|
stdout: true,
|
||||||
|
stderr: true,
|
||||||
|
timestamps: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = demuxLogs(raw);
|
||||||
|
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const firstSpace = line.indexOf(" ");
|
||||||
|
const timestamp = line.slice(0, firstSpace);
|
||||||
|
const message = line.slice(firstSpace + 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp,
|
||||||
|
level: inferLevel(message),
|
||||||
|
source: name,
|
||||||
|
message,
|
||||||
|
} satisfies Log;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllContainerLogs(): Promise<Log[]> {
|
export async function getAllContainerLogs(): Promise<Log[]> {
|
||||||
const nodes = await getNodes();
|
const containers = await docker.listContainers({ all: true });
|
||||||
|
|
||||||
return (
|
const logs = await Promise.all(
|
||||||
await Promise.all(
|
containers.map((c) => getContainerLogs(c.Id, c.Names[0].replace("/", "")))
|
||||||
nodes.map(
|
|
||||||
async (n) =>
|
|
||||||
await (
|
|
||||||
await axios.get(`http://${n.ip}:3000/api/logs`, {
|
|
||||||
headers: { Authorization: `ApiKey ${n.key}` },
|
|
||||||
})
|
|
||||||
).data
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.flat()
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return logs
|
||||||
|
.flat()
|
||||||
|
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import Docker from "dockerode";
|
|
||||||
import { randomUUID } from "crypto";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { exec } from "child_process";
|
|
||||||
import util from "util";
|
|
||||||
import { Application, Env, EnvVariable } from "@/lib/types";
|
|
||||||
|
|
||||||
const docker = new Docker();
|
|
||||||
const execAsync = util.promisify(exec);
|
|
||||||
const DATA_DIR = path.join(process.cwd(), "data", "apps");
|
|
||||||
|
|
||||||
export async function getApplications(): Promise<Application[]> {
|
|
||||||
return fs
|
|
||||||
.readdirSync(DATA_DIR)
|
|
||||||
.map((appId) =>
|
|
||||||
JSON.parse(
|
|
||||||
fs.readFileSync(path.join(DATA_DIR, appId, "app.json")).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installApp(name: string, repo: string, nodeId: string) {
|
|
||||||
// Create app ID
|
|
||||||
const appId = `app_${randomUUID()}`;
|
|
||||||
const appDir = path.join(DATA_DIR, appId);
|
|
||||||
const repoDir = path.join(appDir, "repo");
|
|
||||||
|
|
||||||
// Create directories
|
|
||||||
fs.mkdirSync(repoDir, { recursive: true });
|
|
||||||
|
|
||||||
// Write app.json
|
|
||||||
const appConfig: Application = {
|
|
||||||
id: appId,
|
|
||||||
name,
|
|
||||||
repo,
|
|
||||||
nodeId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
deployments: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(appDir, "app.json"),
|
|
||||||
JSON.stringify(appConfig, null, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Write env.json
|
|
||||||
fs.writeFileSync(path.join(appDir, "env.json"), JSON.stringify([], null, 2));
|
|
||||||
|
|
||||||
// Clone repository
|
|
||||||
try {
|
|
||||||
await execAsync(`git clone ${repo} ${repoDir}`);
|
|
||||||
} catch (err) {
|
|
||||||
throw new Error("Failed to clone repository");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate Dockerfile
|
|
||||||
const dockerfilePath = path.join(repoDir, "Dockerfile");
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.accessSync(dockerfilePath);
|
|
||||||
} catch {
|
|
||||||
fs.rmSync(appDir, { recursive: true });
|
|
||||||
throw new Error("Dockerfile not found in repository root");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
appId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getEnv(): Promise<Record<string, Env>> {
|
|
||||||
const apps = fs.readdirSync(DATA_DIR);
|
|
||||||
|
|
||||||
return apps.reduce((acc, appId) => {
|
|
||||||
const appPath = path.join(DATA_DIR, appId, "app.json");
|
|
||||||
const name = JSON.parse(fs.readFileSync(appPath).toString()).name;
|
|
||||||
|
|
||||||
const envPath = path.join(DATA_DIR, appId, "env.json");
|
|
||||||
if (fs.existsSync(envPath)) {
|
|
||||||
const envData: Record<string, EnvVariable> = JSON.parse(
|
|
||||||
fs.readFileSync(envPath, "utf-8")
|
|
||||||
);
|
|
||||||
acc[appId] = { variables: envData, id: appId, name };
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} 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));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeApp(appId: string) {
|
|
||||||
const appPath = path.join(DATA_DIR, appId);
|
|
||||||
|
|
||||||
const appMeta: Application = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(appPath, "app.json")).toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
appMeta.deployments.forEach(async (imgId) => {
|
|
||||||
// Remove containers
|
|
||||||
(await docker.listContainers({ all: true }))
|
|
||||||
.filter((con) => con.ImageID === imgId)
|
|
||||||
.forEach((con) => {
|
|
||||||
const container = docker.getContainer(con.Id);
|
|
||||||
container.stop();
|
|
||||||
container.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove image
|
|
||||||
const image = docker.getImage(imgId);
|
|
||||||
image.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.rmSync(appPath, { recursive: true });
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { CPUusage } from "@/lib/server/calc";
|
|
||||||
import { Log, SimpleStats } from "@/lib/types";
|
|
||||||
import Docker from "dockerode";
|
|
||||||
|
|
||||||
const docker = new Docker();
|
|
||||||
|
|
||||||
export async function getDocker() {
|
|
||||||
const version = await docker.version();
|
|
||||||
const containers = await docker.listContainers({ all: true });
|
|
||||||
return { version, containers };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getContainerStats(
|
|
||||||
containerId: string
|
|
||||||
): Promise<SimpleStats | undefined> {
|
|
||||||
const container = docker.getContainer(containerId);
|
|
||||||
|
|
||||||
const data = await container.inspect();
|
|
||||||
|
|
||||||
if (!data.State.Running) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await container.stats({ stream: false });
|
|
||||||
|
|
||||||
return {
|
|
||||||
cpu: CPUusage(stats),
|
|
||||||
memory: (stats.memory_stats.usage / stats.memory_stats.limit) * 100,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startContainer(containerId: string) {
|
|
||||||
await docker.getContainer(containerId).start();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopContainer(containerId: string) {
|
|
||||||
await docker.getContainer(containerId).stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeContainer(containerId: string) {
|
|
||||||
await docker.getContainer(containerId).remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferLevel(message: string): "info" | "warn" | "error" {
|
|
||||||
const msg = message.toLowerCase();
|
|
||||||
|
|
||||||
if (msg.includes("error") || msg.includes("fatal")) return "error";
|
|
||||||
if (msg.includes("warn")) return "warn";
|
|
||||||
|
|
||||||
return "info";
|
|
||||||
}
|
|
||||||
|
|
||||||
function demuxLogs(buffer: Buffer): string {
|
|
||||||
let offset = 0;
|
|
||||||
let output = "";
|
|
||||||
|
|
||||||
while (offset < buffer.length) {
|
|
||||||
const streamType = buffer[offset];
|
|
||||||
const length = buffer.readUInt32BE(offset + 4);
|
|
||||||
offset += 8;
|
|
||||||
|
|
||||||
output += buffer.slice(offset, offset + length).toString("utf8");
|
|
||||||
offset += length;
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getContainerLogs(
|
|
||||||
containerId: string,
|
|
||||||
name: string
|
|
||||||
): Promise<Log[]> {
|
|
||||||
const container = docker.getContainer(containerId);
|
|
||||||
|
|
||||||
const raw = await container.logs({
|
|
||||||
stdout: true,
|
|
||||||
stderr: true,
|
|
||||||
timestamps: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = demuxLogs(raw);
|
|
||||||
|
|
||||||
return text
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => {
|
|
||||||
const firstSpace = line.indexOf(" ");
|
|
||||||
const timestamp = line.slice(0, firstSpace);
|
|
||||||
const message = line.slice(firstSpace + 1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp,
|
|
||||||
level: inferLevel(message),
|
|
||||||
source: name,
|
|
||||||
message,
|
|
||||||
} satisfies Log;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAllContainerLogs(): Promise<Log[]> {
|
|
||||||
const containers = await docker.listContainers({ all: true });
|
|
||||||
|
|
||||||
const logs = await Promise.all(
|
|
||||||
containers.map((c) => getContainerLogs(c.Id, c.Names[0].replace("/", "")))
|
|
||||||
);
|
|
||||||
|
|
||||||
return logs
|
|
||||||
.flat()
|
|
||||||
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { verifySession } from "../../core/auth/session";
|
||||||
|
import { getNodes } from "@/lib/dockship/node";
|
||||||
|
import axios from "axios";
|
||||||
|
import { Env, EnvVariable, Application } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function installApp(name: string, repo: string, nodeId: string) {
|
||||||
|
if (await verifySession()) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = await getNodes();
|
||||||
|
|
||||||
|
const targetNode = nodes.find((node) => node.node_id === nodeId);
|
||||||
|
|
||||||
|
return await (
|
||||||
|
await axios.post(
|
||||||
|
`http://${targetNode?.ip}:3000/api/applications`,
|
||||||
|
{ name, repo, nodeId },
|
||||||
|
{
|
||||||
|
headers: { Authorization: `ApiKey ${targetNode?.key}` },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).data.applications;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApplications(): Promise<Application[]> {
|
||||||
|
const nodes = await getNodes();
|
||||||
|
|
||||||
|
return (
|
||||||
|
await Promise.all(
|
||||||
|
nodes.map(
|
||||||
|
async (n) =>
|
||||||
|
await (
|
||||||
|
await axios.get(`http://${n.ip}:3000/api/applications`, {
|
||||||
|
headers: { Authorization: `ApiKey ${n.key}` },
|
||||||
|
})
|
||||||
|
).data.applications
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEnv(): Promise<Record<string, Env>> {
|
||||||
|
const nodes = await getNodes();
|
||||||
|
|
||||||
|
const envRecords = await Promise.all(
|
||||||
|
nodes.map(async (n) => {
|
||||||
|
const response = await axios.get(`http://${n.ip}:3000/api/env`, {
|
||||||
|
headers: { Authorization: `ApiKey ${n.key}` },
|
||||||
|
});
|
||||||
|
return response.data as Record<string, Env>;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const merged: Record<string, Env> = Object.assign({}, ...envRecords);
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setEnv(
|
||||||
|
appId: string,
|
||||||
|
variables: Record<string, 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}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeApp(appId: string) {
|
||||||
|
const nodes = await getNodes();
|
||||||
|
|
||||||
|
nodes.forEach(async (n) => {
|
||||||
|
await axios.delete(`http://${n.ip}:3000/api/applications`, {
|
||||||
|
params: { appId },
|
||||||
|
headers: { Authorization: `ApiKey ${n.key}` },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { Deployment, Docker, Log, Node, SimpleStats } from "@/lib/types";
|
||||||
|
import { verifySession } from "../../core/auth/session";
|
||||||
|
import { getNodes } from "./node";
|
||||||
|
|
||||||
|
export async function getDocker(nodes: Node[]): Promise<Docker[]> {
|
||||||
|
if (await verifySession()) return [];
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
nodes.map(async (node) => {
|
||||||
|
const data = await (
|
||||||
|
await axios.get(`http://${node.ip}:3000/api/container/`, {
|
||||||
|
headers: { Authorization: `ApiKey ${node.key}` },
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
|
||||||
|
return { ...node, containers: data.containers, version: data.version };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContainerStats(
|
||||||
|
containerId: string
|
||||||
|
): Promise<SimpleStats | undefined> {
|
||||||
|
const nodes = await getNodes();
|
||||||
|
|
||||||
|
return (
|
||||||
|
await Promise.all(
|
||||||
|
nodes.map(
|
||||||
|
async (n) =>
|
||||||
|
await (
|
||||||
|
await axios.get(`http://${n.ip}:3000/api/container/stats`, {
|
||||||
|
headers: { Authorization: `ApiKey ${n.key}` },
|
||||||
|
params: { containerId },
|
||||||
|
})
|
||||||
|
).data?.stats
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).find((s) => s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startContainer(containerId: string) {
|
||||||
|
const nodes = await getNodes();
|
||||||
|
|
||||||
|
nodes.forEach(
|
||||||
|
async (n) =>
|
||||||
|
await (
|
||||||
|
await axios.post(
|
||||||
|
`http://${n.ip}:3000/api/container/start`,
|
||||||
|
{ containerId },
|
||||||
|
{
|
||||||
|
headers: { Authorization: `ApiKey ${n.key}` },
|
||||||
|
params: { containerId },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopContainer(containerId: string) {
|
||||||
|
const nodes = await getNodes();
|
||||||
|
|
||||||
|
nodes.forEach(
|
||||||
|
async (n) =>
|
||||||
|
await (
|
||||||
|
await axios.delete(`http://${n.ip}:3000/api/container/start`, {
|
||||||
|
headers: { Authorization: `ApiKey ${n.key}` },
|
||||||
|
params: { containerId },
|
||||||
|
})
|
||||||
|
).data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeContainer(containerId: string) {
|
||||||
|
const nodes = await getNodes();
|
||||||
|
|
||||||
|
nodes.forEach(
|
||||||
|
async (n) =>
|
||||||
|
await (
|
||||||
|
await axios.delete(`http://${n.ip}:3000/api/container`, {
|
||||||
|
headers: { Authorization: `ApiKey ${n.key}` },
|
||||||
|
params: { containerId },
|
||||||
|
})
|
||||||
|
).data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllContainerLogs(): Promise<Log[]> {
|
||||||
|
const nodes = await getNodes();
|
||||||
|
|
||||||
|
return (
|
||||||
|
await Promise.all(
|
||||||
|
nodes.map(
|
||||||
|
async (n) =>
|
||||||
|
await (
|
||||||
|
await axios.get(`http://${n.ip}:3000/api/logs`, {
|
||||||
|
headers: { Authorization: `ApiKey ${n.key}` },
|
||||||
|
})
|
||||||
|
).data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.flat()
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { read, write } from "@/app/api/file";
|
import { read, write } from "@/app/api/file";
|
||||||
import { Node, NodeLiveData } from "@/lib/types";
|
import { Node, NodeLiveData } from "@/lib/types";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { verifySession } from "./auth/session";
|
import { verifySession } from "../../core/auth/session";
|
||||||
|
|
||||||
export async function getNodes(): Promise<Node[]> {
|
export async function getNodes(): Promise<Node[]> {
|
||||||
if (await verifySession()) return [];
|
if (await verifySession()) return [];
|
||||||
Reference in New Issue
Block a user