Compare commits

...
10 Commits
18 changed files with 511 additions and 50 deletions
+15 -1
View File
@@ -40,8 +40,11 @@
## 📚 Table of Contents ## 📚 Table of Contents
- [📚 Table of Contents](#-table-of-contents)
- [About The Project](#about-the-project) - [About The Project](#about-the-project)
- [Built With](#built-with) - [Built With](#built-with)
- [Getting Started](#getting-started)
- [Uninstalling](#uninstalling)
--- ---
@@ -59,7 +62,18 @@
## Getting Started ## Getting Started
Not yet :/ ```bash
wget -qO- https://raw.githubusercontent.com/orus-dev/sentryx/refs/heads/master/install.sh | bash
```
## Uninstalling
```bash
sh $HOME/sentryx-server/uninstall.sh
```
If that fails
```bash
wget -qO- https://raw.githubusercontent.com/orus-dev/sentryx/refs/heads/master/uninstall.sh | bash
```
[contributors-shield]: https://img.shields.io/github/contributors/orus-dev/sentryx.svg?style=flat-square&color=blue&label=contributors [contributors-shield]: https://img.shields.io/github/contributors/orus-dev/sentryx.svg?style=flat-square&color=blue&label=contributors
[contributors-url]: https://github.com/orus-dev/sentryx/graphs/contributors [contributors-url]: https://github.com/orus-dev/sentryx/graphs/contributors
+3
View File
@@ -0,0 +1,3 @@
{
"install": "sh test"
}
+1
View File
@@ -0,0 +1 @@
{}
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
set -e
# Check for required commands
for cmd in node npm git sudo; do
if ! command -v "$cmd" >/dev/null 2>&1; then
echo "Error: Required command '$cmd' is not installed or not in PATH. For more info: https://github.com/orus-dev/sentryx/wiki/Common-errors" >&2
exit 1
fi
done
REPO_URL="https://github.com/orus-dev/sentryx.git"
INSTALL_DIR="$HOME/sentryx"
SERVICE_NAME="sentryx"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
NODE_BIN="$(which node)"
RUN_USER="$(whoami)"
# Backup if exists
if [ -d "$INSTALL_DIR" ]; then
echo "Sentryx already exists, backing up..."
rm -rf "$INSTALL_DIR-backup"
mv "$INSTALL_DIR" "$INSTALL_DIR-backup"
fi
# Clone as current user
git clone "$REPO_URL" "$INSTALL_DIR"
# Build as current user
cd "$INSTALL_DIR"
npm install
npm run build
# Create systemd service
echo "Creating systemd service..."
sudo tee "$SERVICE_FILE" > /dev/null <<EOF
[Unit]
Description=SentryX Server
After=network.target
[Service]
Type=simple
User=$RUN_USER
WorkingDirectory=$INSTALL_DIR
ExecStart="$NODE_BIN" "$INSTALL_DIR/node_modules/.bin/next" start
Restart=on-failure
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now "$SERVICE_NAME.service"
echo "Done! Check service with: sudo systemctl status $SERVICE_NAME"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sentryx", "name": "sentryx",
"version": "0.1.0", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -38,6 +38,6 @@ export default class Data<T> {
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
} }
writeFileSync(this.file_path, JSON.stringify(this.data)); writeFileSync(this.file_path, JSON.stringify(this.data, undefined, 4));
} }
} }
+3 -3
View File
@@ -9,7 +9,7 @@ function average<T>(values: Array<T>, p: (v: T) => number) {
: 0; : 0;
} }
const usageData = Array.from({length: 60}).map((_, i) => ({ hour: i, cpu: 0, ram: 0 })); const usageData = new Array(60).fill({cpu: 0, ram: 0});
var i = 0; var i = 0;
setInterval(() => { setInterval(() => {
@@ -28,7 +28,7 @@ setInterval(() => {
}; };
i++; i++;
} }
}, 60000); }, 1000);
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -47,7 +47,7 @@ export async function GET(request: Request) {
const maxRam = Math.max(...slice.map((entry) => entry.ram)); const maxRam = Math.max(...slice.map((entry) => entry.ram));
peakData.push({ peakData.push({
hour: slice[0].hour, hour: i,
cpu: maxCpu, cpu: maxCpu,
ram: maxRam ram: maxRam
}); });
+1 -1
View File
@@ -185,7 +185,7 @@
--popover-foreground: oklch(0.9108 0.0427 269.5516); --popover-foreground: oklch(0.9108 0.0427 269.5516);
--primary: oklch(0.7182 0.1105 239.784); --primary: oklch(0.7182 0.1105 239.784);
--primary-foreground: oklch(0.3233 0.1025 253.8852); --primary-foreground: oklch(0.3233 0.1025 253.8852);
--secondary: oklch(0.5544 0.0407 257.4166); --secondary: oklch(0.38 0.06 258);
--secondary-foreground: oklch(0.9288 0.0126 255.5078); --secondary-foreground: oklch(0.9288 0.0126 255.5078);
--muted: oklch(0.3923 0.0613 254.3914); --muted: oklch(0.3923 0.0613 254.3914);
--muted-foreground: oklch(0.7584 0.0419 263.598); --muted-foreground: oklch(0.7584 0.0419 263.598);
+1 -1
View File
@@ -80,7 +80,7 @@ export default function NewServerDialog({
disabled={name == "" || ip == ""} disabled={name == "" || ip == ""}
onClick={() => { onClick={() => {
session session
?.addServer({ name, ip, coordinates, location }) ?.addServer({ name, ip, coordinates, location, })
.then(() => { .then(() => {
toast.success( toast.success(
"Server added successfully, refresh to view it" "Server added successfully, refresh to view it"
+78
View File
@@ -0,0 +1,78 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button, buttonVariants } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import Session from "@/lib/session";
import { toast } from "sonner";
export default function AddAppDialog({
session,
}: {
session: Session | null;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [ip, setIp] = useState("");
const [location, setLocation] = useState<string | undefined>(undefined);
const [coordinates, setCoordinates] = useState<[number, number] | undefined>(
undefined
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
className={cn(buttonVariants({ variant: "default" }))}
onClick={() => setOpen(true)}
>
<Plus className="m-0 p-0" /> New
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add Application</DialogTitle>
</DialogHeader>
<Label className="mt-2">
Application Name<span className="text-red-500">*</span>
</Label>
<Input
placeholder="Sentryx chat, My Project, etc"
required
onInput={(e) => setName(e.currentTarget.value)}
/>
<Label className="mt-2">
Application service<span className="text-red-500">*</span>
</Label>
<Input
placeholder="my-app.service"
required
onInput={(e) => setIp(e.currentTarget.value)}
/>
<DialogFooter>
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={name == "" || ip == ""}
onClick={() => {
setOpen(false);
}}
>
Add App
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+114
View File
@@ -0,0 +1,114 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import Session from "@/lib/session";
import Service, { AppService } from "@/types/service";
import { AppWindowMac } from "lucide-react";
import { redirect } from "next/navigation";
import { useState } from "react";
export function UsageBar({
value,
Icon,
text,
color,
}: {
value: number;
Icon: React.ElementType;
text: string;
color: string;
}) {
return (
<div className="flex flex-col gap-2 w-full">
<div className="justify-between flex">
<Label className="text-md flex justify-center">
<Icon size={16} color={color} /> {text}
</Label>
<Label className="text-md">{value}%</Label>
</div>
<Progress value={value} />
</div>
);
}
function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
return `${size} ${sizes[i]}`;
}
export function ByteUnit({
value,
Icon,
text,
color,
}: {
value: number;
Icon: React.ElementType;
text: string;
color: string;
}) {
return (
<div className="flex flex-col gap-2 w-full">
<div className="justify-between flex">
<Label className="text-md flex justify-center">
<Icon size={16} color={color} /> {text}
</Label>
<Label className="text-md">{formatBytes(value)}</Label>
</div>
</div>
)
}
export default function AppComponent({
service,
index,
app
}: {
service: Service;
app: AppService;
index: number;
}) {
const [deleteAlert, setDeleteAlert] = useState(false);
return (
<Card
className="w-full transition-transform duration-500 hover:scale-102 hover:cursor-pointer !select-none border-secondary border-2"
onClick={() => {
redirect(`/servers/${index}`);
}}
>
<CardHeader>
<div className="flex items-center gap-2 overflow-clip">
<div className="bg-muted text-muted-foreground p-1.5 rounded-md">
<AppWindowMac className="w-4.5 h-4.5 text-current my-auto" />
</div>
<div>
<CardTitle>{app.name}</CardTitle>
{(service.description && <CardDescription className="flex justify-between">
<div><p className={service.active_state}>{service.active_state[0].toUpperCase() + service.active_state.slice(1)}</p> {service.description}</div>
{(service as any).cpu}
</CardDescription>)}
</div>
</div>
</CardHeader>
<CardContent className="w-full flex flex-col gap-3.5">
</CardContent>
</Card>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import useSession from "@/hooks/useSession";
import Server from "@/types/server";
import { useEffect, useState } from "react";
import ServiceComponent from "./service";
import ServerComponent from "@/app/dashboard/server";
import Service, { AppService } from "@/types/service";
import AppComponent from "./AppComponent";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import AddAppDialog from "./AddAppDialog";
import InstallAppDialog from "./IntallAppDialog";
interface Server2 extends Server {
services: Service[],
apps: AppService[],
}
export default function ClientPage({ serverId }: { serverId: string }) {
const index = parseInt(serverId) || 0;
const session = useSession();
const [server, setServer] = useState<Server2 | undefined>();
useEffect(() => {
session?.getServerIndex(index).then((s) => {
setServer(s.data);
setInterval(() => {
session?.getServerIndex(index).then((s) => {
setServer(s.data);
});
}, 1000);
});
}, [session]);
return server && (
<div className="gap-4 flex flex-col">
<div>
<ServerComponent server={server} session={session} index={index} nonInteractive />
</div>
<Card>
<CardHeader className="flex">
<div>
<CardTitle>Sentryx Applications</CardTitle>
<CardDescription>Your Sentryx apps on this server</CardDescription>
</div>
<div className="ml-auto w-max flex gap-3">
<InstallAppDialog session={session} />
<AddAppDialog session={session} />
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{server.apps.map((a, i) => {
const srv = server.services.find(s => s.name === a.service);
return srv && <AppComponent key={i} service={srv} index={i} app={a} />;
})}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{server.services.map((a, i) => (
<ServiceComponent key={i} service={a} index={i} session={session} />
))}
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button, buttonVariants } from "@/components/ui/button";
import { HardDriveDownload, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import Session from "@/lib/session";
import { toast } from "sonner";
import { FileUpload } from "@/components/FileUpload";
export default function InstallAppDialog({
session,
}: {
session: Session | null;
}) {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [script, setScript] = useState<{ install?: string } | undefined>({});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
className={cn(buttonVariants({ variant: "secondary" }))}
onClick={() => setOpen(true)}
>
<HardDriveDownload className="m-0 p-0" /> Install
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Install Application</DialogTitle>
</DialogHeader>
<Label className="mt-2">
Application Name<span className="text-red-500">*</span>
</Label>
<Input
placeholder="Sentryx chat, My Project, etc"
required
onInput={(e) => setName(e.currentTarget.value)}
/>
<Label className="mt-2">
Application script<span className="text-red-500">*</span>
</Label>
<FileUpload setSelected={(contents) => {
try {
setScript(JSON.parse(contents))
console.log(contents)
} catch {
setScript(undefined)
}
}} />
<DialogFooter>
<Button variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={name == "" || !script || !(script.install)}
onClick={() => {
setOpen(false);
}}
>
Install
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+3 -40
View File
@@ -1,42 +1,5 @@
"use client"; import ClientPage from './ClientPage';
import useSession from "@/hooks/useSession";
import Server from "@/types/server";
import { useEffect, useState } from "react";
import ServiceComponent from "./service";
import ServerComponent from "@/app/dashboard/server";
import Service, { AppService } from "@/types/service";
interface Server2 extends Server { export default async function ServerPage({ params }: { params: Promise<{ server: string }> }) {
services: Service[], return <ClientPage serverId={(await params).server} />;
apps: AppService[],
}
export default function ServerPage(params: { server: string }) {
const index = parseInt(params.server) || 0;
const session = useSession();
const [server, setServer] = useState<Server2 | undefined>();
useEffect(() => {
session?.getServerIndex(index).then((s) => {
setServer(s.data);
setInterval(() => {
session?.getServerIndex(index).then((s) => {
setServer(s.data);
});
}, 1000);
});
}, [session]);
return server && (
<div className="gap-4 flex flex-col">
<div>
<ServerComponent server={server} session={session} index={index} nonInteractive />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{server.services.map((a, i) => (
<ServiceComponent key={i} service={a} index={i} session={session} / >
))}
</div>
</div>
);
} }
+62
View File
@@ -0,0 +1,62 @@
import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils" // optional utility for combining classes
export function FileUpload({setSelected} : {setSelected: (contents: string) => void}) {
const inputRef = useRef<HTMLInputElement | null>(null)
const [fileName, setFileName] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [isUnsupported, setIsUnsupported] = useState(false)
const handleFiles = (files: FileList | null) => {
const file = files?.[0]
if (!file) return
setIsUnsupported(!file.name.endsWith('.json'));
if (isUnsupported) return
setFileName(file.name)
const reader = new FileReader()
reader.onload = (e) => {
setSelected(e.target?.result as string)
}
reader.readAsText(file)
}
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
}
return (
<div className="space-y-2">
<input
ref={inputRef}
type="file"
onChange={(e) => handleFiles(e.target.files)}
className="hidden"
/>
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault()
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={cn(
"flex h-32 cursor-pointer items-center justify-center rounded-md border-2 border-dashed transition-colors",
isDragging ? "border-primary bg-muted" : "border-muted",
isUnsupported && "border-destructive"
)}
>
<span className="text-muted-foreground">
{isUnsupported ? "Unsupported file type" : (fileName || "Click or drag file to upload")}
</span>
</div>
</div>
)
}
+1 -1
View File
@@ -5,7 +5,7 @@ export interface ServerAPI {
ip: string; ip: string;
location?: string; location?: string;
coordinates?: [number, number]; coordinates?: [number, number];
apps: AppService[]; apps?: AppService[];
} }
export default interface Server extends ServerAPI { export default interface Server extends ServerAPI {
+1 -1
View File
@@ -1,2 +1,2 @@
export interface AppService { name?: string, description?: string }; export interface AppService { name?: string, description?: string, service: string };
export default interface Service { name?: string, description?: string, active_state: string }; export default interface Service { name?: string, description?: string, active_state: string };
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
set -e
SERVICE_NAME="sentryx"
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service"
INSTALL_DIR="$HOME/sentryx"
echo "Stopping and disabling systemd service..."
sudo systemctl stop "$SERVICE_NAME.service" || true
sudo systemctl disable "$SERVICE_NAME.service" || true
echo "Removing systemd service file..."
sudo rm -f "$SERVICE_FILE"
echo "Reloading systemd daemon..."
sudo systemctl daemon-reload
echo "Removing installation directory..."
rm -rf "$INSTALL_DIR"
rm -rf "${INSTALL_DIR}-backup"
echo "Uninstall complete."