diff --git a/main.go b/main.go index d628320..dfc19c4 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,10 @@ package main import ( + "bytes" + "crypto/ed25519" + "encoding/json" + "encoding/pem" "fmt" "log" _ "monitor-site/migrations" @@ -14,12 +18,17 @@ import ( "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/pocketbase/pocketbase/tools/cron" + "golang.org/x/crypto/ssh" ) +var app *pocketbase.PocketBase +var serverConnections = make(map[string]Server) + func main() { - app := pocketbase.New() + app = pocketbase.New() // loosely check if it was executed using "go run" isGoRun := strings.HasPrefix(os.Args[0], os.TempDir()) @@ -58,12 +67,12 @@ func main() { // Format the time as a string timeString := oneMonthAgo.Format("2006-01-02 15:04:05") // collections to be cleaned - collections := []string{"systems", "system_stats", "container_stats"} + collections := []string{"system_stats", "container_stats"} for _, collection := range collections { records, err := app.Dao().FindRecordsByFilter( collection, - fmt.Sprintf("updated <= \"%s\"", timeString), // filter + fmt.Sprintf("created <= \"%s\"", timeString), // filter "", // sort -1, // limit 0, // offset @@ -84,7 +93,220 @@ func main() { return nil }) + app.OnBeforeServe().Add(func(e *core.ServeEvent) error { + go serverUpdateTicker() + return nil + }) + if err := app.Start(); err != nil { log.Fatal(err) } } + +func serverUpdateTicker() { + ticker := time.NewTicker(5 * time.Second) + for range ticker.C { + updateServers() + } +} + +func updateServers() { + // serverCount := len(serverConnections) + // fmt.Println("server count: ", serverCount) + query := app.Dao().RecordQuery("systems"). + // todo check that asc is correct + OrderBy("updated ASC"). + // todo get total count of servers and divide by 4 or something + Limit(1) + + records := []*models.Record{} + if err := query.All(&records); err != nil { + app.Logger().Error("Failed to get servers: ", "err", err) + // return nil, err + } + + for _, record := range records { + var server Server + // check if server connection data exists + if _, ok := serverConnections[record.Id]; ok { + server = serverConnections[record.Id] + } else { + // create server connection struct + server = Server{ + Ip: record.Get("ip").(string), + Port: record.Get("port").(string), + } + client, err := getServerConnection(&server) + if err != nil { + app.Logger().Error("Failed to connect:", "err", err, "server", server.Ip, "port", server.Port) + // todo update record to not connected + record.Set("active", false) + delete(serverConnections, record.Id) + continue + } + server.Client = client + serverConnections[record.Id] = server + } + // get server stats + systemData, err := requestJson(&server) + if err != nil { + app.Logger().Error("Failed to get server stats: ", "err", err) + record.Set("active", false) + if server.Client != nil { + server.Client.Close() + } + delete(serverConnections, record.Id) + continue + } + // update system record + record.Set("active", true) + record.Set("stats", systemData.System) + if err := app.Dao().SaveRecord(record); err != nil { + app.Logger().Error("Failed to update record: ", "err", err) + } + // add new system_stats record + system_stats, _ := app.Dao().FindCollectionByNameOrId("system_stats") + system_stats_record := models.NewRecord(system_stats) + system_stats_record.Set("system", record.Id) + system_stats_record.Set("stats", systemData.System) + if err := app.Dao().SaveRecord(system_stats_record); err != nil { + app.Logger().Error("Failed to save record: ", "err", err) + } + // add new container_stats record + if len(systemData.Containers) > 0 { + container_stats, _ := app.Dao().FindCollectionByNameOrId("container_stats") + container_stats_record := models.NewRecord(container_stats) + container_stats_record.Set("system", record.Id) + container_stats_record.Set("stats", systemData.Containers) + if err := app.Dao().SaveRecord(container_stats_record); err != nil { + app.Logger().Error("Failed to save record: ", "err", err) + } + } + } +} + +func getServerConnection(server *Server) (*ssh.Client, error) { + // app.Logger().Debug("new ssh connection", "server", server.Ip) + key, err := getSSHKey() + if err != nil { + app.Logger().Error("Failed to get SSH key: ", "err", err) + return nil, err + } + time.Sleep(time.Second) + + // Create the Signer for this private key. + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + return nil, err + } + + config := &ssh.ClientConfig{ + User: "u", + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + + client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", server.Ip, server.Port), config) + if err != nil { + return nil, err + } + + return client, nil +} + +func requestJson(server *Server) (SystemData, error) { + session, err := server.Client.NewSession() + if err != nil { + return SystemData{}, err + } + defer session.Close() + + // Create a buffer to capture the output + var outputBuffer bytes.Buffer + session.Stdout = &outputBuffer + + if err := session.Shell(); err != nil { + return SystemData{}, err + } + + err = session.Wait() + if err != nil { + return SystemData{}, err + } + + // Unmarshal the output into our struct + var systemData SystemData + err = json.Unmarshal(outputBuffer.Bytes(), &systemData) + if err != nil { + return SystemData{}, err + } + + return systemData, nil +} + +func getSSHKey() ([]byte, error) { + // check if the key pair already exists + existingKey, err := os.ReadFile("./pb_data/id_ed25519") + if err == nil { + return existingKey, nil + } + + // Generate the Ed25519 key pair + pubKey, privKey, err := ed25519.GenerateKey(nil) + if err != nil { + // app.Logger().Error("Error generating key pair:", "err", err) + return nil, err + } + + // Get the private key in OpenSSH format + privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "") + if err != nil { + // app.Logger().Error("Error marshaling private key:", "err", err) + return nil, err + } + + // Save the private key to a file + privateFile, err := os.Create("./pb_data/id_ed25519") + if err != nil { + // app.Logger().Error("Error creating private key file:", "err", err) + return nil, err + } + defer privateFile.Close() + + if err := pem.Encode(privateFile, privKeyBytes); err != nil { + // app.Logger().Error("Error writing private key to file:", "err", err) + return nil, err + } + + // Generate the public key in OpenSSH format + publicKey, err := ssh.NewPublicKey(pubKey) + if err != nil { + return nil, err + } + + pubKeyBytes := ssh.MarshalAuthorizedKey(publicKey) + + // Save the public key to a file + publicFile, err := os.Create("./pb_data/id_ed25519.pub") + if err != nil { + return nil, err + } + defer publicFile.Close() + + if _, err := publicFile.Write(pubKeyBytes); err != nil { + return nil, err + } + + app.Logger().Info("ed25519 SSH key pair generated successfully.") + app.Logger().Info("Private key saved to: pb_data/id_ed25519") + app.Logger().Info("Public key saved to: pb_data/id_ed25519.pub") + + existingKey, err = os.ReadFile("./pb_data/id_ed25519") + if err == nil { + return existingKey, nil + } + return nil, err +} diff --git a/migrations/1720385358_collections_snapshot.go b/migrations/1720568457_collections_snapshot.go similarity index 84% rename from migrations/1720385358_collections_snapshot.go rename to migrations/1720568457_collections_snapshot.go index e25bfaa..7e67ab4 100644 --- a/migrations/1720385358_collections_snapshot.go +++ b/migrations/1720568457_collections_snapshot.go @@ -15,7 +15,7 @@ func init() { { "id": "_pb_users_auth_", "created": "2024-07-07 15:59:04.262Z", - "updated": "2024-07-07 15:59:04.264Z", + "updated": "2024-07-07 20:52:28.847Z", "name": "users", "type": "auth", "system": false, @@ -78,7 +78,7 @@ func init() { { "id": "2hz5ncl8tizk5nx", "created": "2024-07-07 16:08:20.979Z", - "updated": "2024-07-07 20:48:21.553Z", + "updated": "2024-07-09 22:46:22.047Z", "name": "systems", "type": "base", "system": false, @@ -97,6 +97,44 @@ func init() { "pattern": "" } }, + { + "system": false, + "id": "4fbh8our", + "name": "active", + "type": "bool", + "required": false, + "presentable": false, + "unique": false, + "options": {} + }, + { + "system": false, + "id": "ve781smf", + "name": "ip", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, + { + "system": false, + "id": "pij0k2jk", + "name": "port", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "pattern": "" + } + }, { "system": false, "id": "qoq64ntl", @@ -123,7 +161,7 @@ func init() { { "id": "ej9oowivz8b2mht", "created": "2024-07-07 16:09:09.179Z", - "updated": "2024-07-07 20:48:44.878Z", + "updated": "2024-07-07 20:52:28.848Z", "name": "system_stats", "type": "base", "system": false, @@ -170,7 +208,7 @@ func init() { { "id": "juohu4jipgc13v7", "created": "2024-07-07 16:09:57.976Z", - "updated": "2024-07-07 20:42:14.559Z", + "updated": "2024-07-07 20:52:28.848Z", "name": "container_stats", "type": "base", "system": false, diff --git a/site/bun.lockb b/site/bun.lockb index c35f27c..ad4c3d9 100755 Binary files a/site/bun.lockb and b/site/bun.lockb differ diff --git a/site/index.html b/site/index.html index 40366c3..22e3fed 100644 --- a/site/index.html +++ b/site/index.html @@ -1,13 +1,19 @@ - + - - - - - Vite + Preact + TS - - -
- - + + + + + Home + + + + + +
+ + diff --git a/site/src/components/add-server.tsx b/site/src/components/add-server.tsx new file mode 100644 index 0000000..3c19a31 --- /dev/null +++ b/site/src/components/add-server.tsx @@ -0,0 +1,119 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { pb } from '@/lib/stores' +import { Plus } from 'lucide-react' +import { MutableRef, useRef, useState } from 'preact/hooks' + +function copyDockerCompose(port: string) { + console.log('copying docker compose') + navigator.clipboard.writeText(`services: + agent: + image: 'henrygd/monitor-agent' + container_name: 'monitor-agent' + restart: unless-stopped + ports: + - '${port}:45876' + volumes: + - /var/run/docker.sock:/var/run/docker.sock`) +} + +export function AddServerButton() { + const [open, setOpen] = useState(false) + const port = useRef() as MutableRef + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + const formData = new FormData(e.target as HTMLFormElement) + const stats = { + cpu: 0, + mem: 0, + memUsed: 0, + memPct: 0, + disk: 0, + diskUsed: 0, + diskPct: 0, + } + const data = { stats } as Record + for (const [key, value] of formData) { + data[key.slice(2)] = value + } + console.log(data) + + try { + const record = await pb.collection('systems').create(data) + console.log(record) + setOpen(false) + } catch (e) { + console.log(e) + } + } + + return ( + + + + + + + Add New Server + + The agent must be running on the server to connect. Copy the{' '} + docker-compose.yml for the agent below. + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ ) +} diff --git a/site/src/components/routes/home.tsx b/site/src/components/routes/home.tsx index fb78072..a9162c8 100644 --- a/site/src/components/routes/home.tsx +++ b/site/src/components/routes/home.tsx @@ -53,7 +53,7 @@ export function Home() { All Servers Press{' '} - + K {' '} to open the command palette. diff --git a/site/src/components/server-table/data-table.tsx b/site/src/components/server-table/data-table.tsx index b5ac83f..31d558b 100644 --- a/site/src/components/server-table/data-table.tsx +++ b/site/src/components/server-table/data-table.tsx @@ -20,7 +20,7 @@ import { TableRow, } from '@/components/ui/table' -import { Button } from '@/components/ui/button' +import { Button, buttonVariants } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { @@ -44,28 +44,29 @@ import { } from '@/components/ui/alert-dialog' import { SystemRecord } from '@/types' -import { MoreHorizontal, ArrowUpDown, Copy, RefreshCcw, Eye } from 'lucide-react' +import { MoreHorizontal, ArrowUpDown, Copy, RefreshCcw } from 'lucide-react' import { useMemo, useState } from 'preact/hooks' import { navigate } from 'wouter-preact/use-browser-location' import { $servers, pb } from '@/lib/stores' import { useStore } from '@nanostores/preact' +import { AddServerButton } from '../add-server' +import clsx from 'clsx' +import { cn } from '@/lib/utils' function CellFormatter(info: CellContext) { const val = info.getValue() as number - let background = '#42b768' + let color = 'green' if (val > 80) { - // red - background = '#da2a49' + color = 'red' } else if (val > 50) { - // yellow - background = '#daa42a' + color = 'yellow' } return (
{val.toFixed(2)}% @@ -90,6 +91,8 @@ export function DataTable() { const data = useStore($servers) const [liveUpdates, setLiveUpdates] = useState(true) const [deleteServer, setDeleteServer] = useState({} as SystemRecord) + const [sorting, setSorting] = useState([]) + const [columnFilters, setColumnFilters] = useState([]) const columns: ColumnDef[] = useMemo( () => [ @@ -97,8 +100,15 @@ export function DataTable() { // size: 70, accessorKey: 'name', cell: (info) => ( - - {info.getValue() as string}{' '} + + + {info.getValue() as string} - {/* */} ), header: ({ column }) => sortableHeader(column, 'Server'), @@ -151,7 +160,7 @@ export function DataTable() { > View details - navigator.clipboard.writeText(system.id)}> + navigator.clipboard.writeText(system.ip)}> Copy IP address @@ -172,10 +181,6 @@ export function DataTable() { [] ) - const [sorting, setSorting] = useState([]) - - const [columnFilters, setColumnFilters] = useState([]) - const table = useReactTable({ data, columns, @@ -191,79 +196,89 @@ export function DataTable() { }) return ( -
-
- table.getColumn('name')?.setFilterValue(event.target.value)} - className="max-w-sm" - /> -
- {liveUpdates || ( - + )} + {/* - )} - -
-
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ) + - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + /> + Live Updates + */} + + + +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} - )) - ) : ( - - - No results. - - - )} - -
+ ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No servers found + + + )} + + +
@@ -281,6 +296,7 @@ export function DataTable() { Cancel { setDeleteServer({} as SystemRecord) pb.collection('systems').delete(deleteServer.id) @@ -291,6 +307,6 @@ export function DataTable() { -
+ ) } diff --git a/site/src/index.css b/site/src/index.css index 0fa1f17..92c5c12 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -13,7 +13,7 @@ --primary-foreground: 0 0% 100%; --secondary: 240 4.76% 95.88%; --secondary-foreground: 240 5.88% 10%; - --muted: 0 5.56% 94%; + --muted: 26 6% 90%; --muted-foreground: 24 2.79% 35.1%; --accent: 20 23.08% 93%; --accent-foreground: 240 5.88% 10%; diff --git a/site/src/lib/stores.ts b/site/src/lib/stores.ts index ab469ee..ed315d3 100644 --- a/site/src/lib/stores.ts +++ b/site/src/lib/stores.ts @@ -6,9 +6,9 @@ export const pb = new PocketBase('/') // @ts-ignore pb.authStore.storageKey = 'pb_admin_auth' -export const $authenticated = atom(pb.authStore.isValid) export const $servers = atom([] as SystemRecord[]) +export const $authenticated = atom(pb.authStore.isValid) pb.authStore.onChange(() => { $authenticated.set(pb.authStore.isValid) }) diff --git a/site/src/main.tsx b/site/src/main.tsx index cbafbde..9bdfc14 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -1,6 +1,6 @@ import './index.css' import { render } from 'preact' -import { Link, Route, Switch } from 'wouter-preact' +import { Route, Switch } from 'wouter-preact' import { Home } from './components/routes/home.tsx' import { ThemeProvider } from './components/theme-provider.tsx' import LoginPage from './components/login.tsx' diff --git a/site/src/types.d.ts b/site/src/types.d.ts index 9788f77..0c3ea2e 100644 --- a/site/src/types.d.ts +++ b/site/src/types.d.ts @@ -2,6 +2,9 @@ import { RecordModel } from 'pocketbase' export interface SystemRecord extends RecordModel { name: string + ip: string + active: boolean + port: string stats: SystemStats } diff --git a/site/tailwind.config.js b/site/tailwind.config.js index 7cb7e37..a6d13a8 100644 --- a/site/tailwind.config.js +++ b/site/tailwind.config.js @@ -1,77 +1,95 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - darkMode: ["class"], - content: [ - './pages/**/*.{ts,tsx}', - './components/**/*.{ts,tsx}', - './app/**/*.{ts,tsx}', - './src/**/*.{ts,tsx}', - ], - prefix: "", - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - }, - }, - plugins: [require("tailwindcss-animate")], -} \ No newline at end of file + darkMode: ['class'], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: '', + theme: { + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px', + }, + }, + fontFamily: { + sans: ['Inter', 'sans-serif'], + // body: ['Inter', 'sans-serif'], + // display: ['Inter', 'sans-serif'], + }, + extend: { + colors: { + green: { + 50: '#EBF9F0', + 100: '#D8F3E1', + 200: '#ADE6C0', + 300: '#85DBA2', + 400: '#5ACE81', + 500: '#38BB63', + 600: '#2D954F', + 700: '#22723D', + 800: '#164B28', + 900: '#0C2715', + 950: '#06140A', + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + keyframes: { + 'accordion-down': { + from: { height: '0' }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: '0' }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [require('tailwindcss-animate')], +} diff --git a/site/vite.config.ts b/site/vite.config.ts index 53d3e35..c420d96 100644 --- a/site/vite.config.ts +++ b/site/vite.config.ts @@ -4,6 +4,9 @@ import path from 'path' export default defineConfig({ plugins: [preact()], + esbuild: { + legalComments: 'external', + }, resolve: { alias: { '@': path.resolve(__dirname, './src'), diff --git a/types.go b/types.go new file mode 100644 index 0000000..acb8b95 --- /dev/null +++ b/types.go @@ -0,0 +1,33 @@ +package main + +import ( + "golang.org/x/crypto/ssh" +) + +type Server struct { + Ip string + Port string + Client *ssh.Client +} + +type SystemData struct { + System SystemStats `json:"stats"` + Containers []ContainerStats `json:"container"` +} + +type SystemStats struct { + Cpu float64 `json:"cpu"` + Mem float64 `json:"mem"` + MemUsed float64 `json:"memUsed"` + MemPct float64 `json:"memPct"` + Disk float64 `json:"disk"` + DiskUsed float64 `json:"diskUsed"` + DiskPct float64 `json:"diskPct"` +} + +type ContainerStats struct { + Name string `json:"name"` + Cpu float64 `json:"cpu"` + Mem float64 `json:"mem"` + MemPct float64 `json:"memPct"` +}