diff --git a/.gitignore b/.gitignore
index 86aac31..2861de9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@ monitor-site
.idea.md
pb_data
data
-temp
\ No newline at end of file
+temp
+.vscode
\ No newline at end of file
diff --git a/dockerfile b/dockerfile
new file mode 100644
index 0000000..9bcce5d
--- /dev/null
+++ b/dockerfile
@@ -0,0 +1,29 @@
+FROM --platform=$BUILDPLATFORM golang:alpine as builder
+
+WORKDIR /app
+
+# Download Go modules
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY *.go ./
+COPY migrations ./migrations
+
+# Build
+ARG TARGETOS TARGETARCH
+RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /server .
+
+# ? -------------------------
+FROM alpine:latest
+
+RUN apk add --no-cache \
+ unzip \
+ ca-certificates
+
+COPY --from=builder /server /
+
+COPY ./site/dist /site/dist
+
+EXPOSE 8080
+
+CMD ["/server", "serve", "--http=0.0.0.0:8080"]
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 620379b..f86c18e 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,11 @@ module monitor-site
go 1.22.4
-require github.com/pocketbase/pocketbase v0.22.16
+require (
+ github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61
+ github.com/pocketbase/dbx v1.10.1
+ github.com/pocketbase/pocketbase v0.22.16
+)
require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
@@ -42,13 +46,11 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
- github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
- github.com/pocketbase/dbx v1.10.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect
diff --git a/main.go b/main.go
index f6b1812..d628320 100644
--- a/main.go
+++ b/main.go
@@ -3,17 +3,19 @@ package main
import (
"fmt"
"log"
+ _ "monitor-site/migrations"
+ "net/http/httputil"
+ "net/url"
"os"
"strings"
"time"
+ "github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/cron"
-
- _ "monitor-site/migrations"
)
func main() {
@@ -21,12 +23,29 @@ func main() {
// loosely check if it was executed using "go run"
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
+
// enable auto creation of migration files when making collection changes in the Admin UI
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
// (the isGoRun check is to enable it only during development)
Automigrate: isGoRun,
})
+ // serve site
+ app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
+ switch isGoRun {
+ case true:
+ proxy := httputil.NewSingleHostReverseProxy(&url.URL{
+ Scheme: "http",
+ Host: "localhost:5173",
+ })
+ e.Router.Any("/*", echo.WrapHandler(proxy))
+ e.Router.Any("/", echo.WrapHandler(proxy))
+ default:
+ e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS("./site/dist"), true))
+ }
+ return nil
+ })
+
// set up cron job to delete records older than 30 days
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
scheduler := cron.New()
@@ -65,12 +84,6 @@ func main() {
return nil
})
- // serves static files from the provided public dir (if exists)
- app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
- e.Router.GET("/*", apis.StaticDirectoryHandler(os.DirFS("./pb_public"), false))
- return nil
- })
-
if err := app.Start(); err != nil {
log.Fatal(err)
}
diff --git a/site/.gitignore b/site/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/site/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/site/bun.lockb b/site/bun.lockb
new file mode 100755
index 0000000..890127a
Binary files /dev/null and b/site/bun.lockb differ
diff --git a/site/components.json b/site/components.json
new file mode 100644
index 0000000..5b4a89e
--- /dev/null
+++ b/site/components.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "default",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "src/index.css",
+ "baseColor": "gray",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils"
+ }
+}
\ No newline at end of file
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..40366c3
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + Preact + TS
+
+
+
+
+
+
diff --git a/site/package.json b/site/package.json
new file mode 100644
index 0000000..369a7d7
--- /dev/null
+++ b/site/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "site",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@nanostores/preact": "^0.5.1",
+ "@radix-ui/react-dropdown-menu": "^2.1.1",
+ "@radix-ui/react-label": "^2.1.0",
+ "@radix-ui/react-slot": "^1.1.0",
+ "@tanstack/react-table": "^8.19.2",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.401.0",
+ "nanostores": "^0.10.3",
+ "pocketbase": "^0.21.3",
+ "preact": "^10.22.0",
+ "tailwind-merge": "^2.4.0",
+ "tailwindcss-animate": "^1.0.7",
+ "valibot": "^0.36.0",
+ "wouter-preact": "^3.3.1"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "^2.8.2",
+ "@types/bun": "^1.1.6",
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.39",
+ "tailwindcss": "^3.4.4",
+ "typescript": "^5.2.2",
+ "vite": "^5.3.1"
+ }
+}
diff --git a/site/postcss.config.js b/site/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/site/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/site/public/vite.svg b/site/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/site/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/site/src/components/login.tsx b/site/src/components/login.tsx
new file mode 100644
index 0000000..afea7ea
--- /dev/null
+++ b/site/src/components/login.tsx
@@ -0,0 +1,62 @@
+import { Link } from 'wouter-preact'
+
+import { cn } from '@/lib/utils'
+import { buttonVariants } from '@/components/ui/button'
+import { UserAuthForm } from '@/components/user-auth-form'
+import { ChevronLeft } from 'lucide-react'
+
+export default function LoginPage() {
+ return (
+
+
+
+
+
+
Welcome back
+
+ Enter your email to sign in to your account
+
+
+
+
+
+ Don't have an account? Sign Up
+
+
+
+
+
+
+
+ {/*
+
+
+ “This library has saved me countless hours of work and helped me deliver stunning
+ designs to my clients faster than ever before.”
+
+
+
+
*/}
+
+
+ )
+}
diff --git a/site/src/components/mode-toggle.tsx b/site/src/components/mode-toggle.tsx
new file mode 100644
index 0000000..5ab0bcf
--- /dev/null
+++ b/site/src/components/mode-toggle.tsx
@@ -0,0 +1,31 @@
+import { Moon, Sun } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { useTheme } from '@/components/theme-provider'
+
+export function ModeToggle() {
+ const { setTheme } = useTheme()
+
+ return (
+
+
+
+
+
+ setTheme('light')}>Light
+ setTheme('dark')}>Dark
+ setTheme('system')}>System
+
+
+ )
+}
diff --git a/site/src/components/routes/home.tsx b/site/src/components/routes/home.tsx
new file mode 100644
index 0000000..40b45cd
--- /dev/null
+++ b/site/src/components/routes/home.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useState } from 'preact/hooks'
+import { pb } from '@/lib/stores'
+import { SystemRecord } from '@/types'
+import { DataTable } from '../server-table/data-table'
+
+export function Home() {
+ const [systems, setSystems] = useState([] as SystemRecord[])
+
+ useEffect(() => {
+ pb.collection('systems')
+ .getList(1, 20)
+ .then(({ items }) => {
+ setSystems(items)
+ })
+
+ pb.collection('systems').subscribe('*', (e) => {
+ setSystems((curSystems) => {
+ const i = curSystems.findIndex((s) => s.id === e.record.id)
+ if (i > -1) {
+ const newSystems = [...systems]
+ newSystems[i] = e.record
+ return newSystems
+ } else {
+ return [...curSystems, e.record]
+ }
+ })
+ })
+ return () => pb.collection('systems').unsubscribe('*')
+ }, [])
+
+ return (
+ <>
+ Dashboard
+ {systems.length && }
+ {JSON.stringify(systems, null, 2)}
+ >
+ )
+}
diff --git a/site/src/components/routes/server.tsx b/site/src/components/routes/server.tsx
new file mode 100644
index 0000000..255d0f3
--- /dev/null
+++ b/site/src/components/routes/server.tsx
@@ -0,0 +1,12 @@
+import { useEffect } from 'preact/hooks'
+import { useRoute } from 'wouter-preact'
+
+export function ServerDetail() {
+ const [_, params] = useRoute('/server/:name')
+
+ useEffect(() => {
+ document.title = `Server: ${params!.name}`
+ }, [])
+
+ return <>Info for {params!.name}>
+}
diff --git a/site/src/components/server-table/columns.tsx b/site/src/components/server-table/columns.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/site/src/components/server-table/data-table.tsx b/site/src/components/server-table/data-table.tsx
new file mode 100644
index 0000000..6dce061
--- /dev/null
+++ b/site/src/components/server-table/data-table.tsx
@@ -0,0 +1,111 @@
+import {
+ CellContext,
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ useReactTable,
+} from '@tanstack/react-table'
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+
+import { SystemRecord } from '@/types'
+
+function CellFormatter(info: CellContext) {
+ const val = info.getValue() as number
+ let background = '#42b768'
+ if (val > 25) {
+ background = '#da2a49'
+ } else if (val > 10) {
+ background = '#daa42a'
+ }
+ return (
+
+ {val.toFixed(2)}%
+
+
+
+
+ )
+}
+
+export function DataTable({ data }: { data: SystemRecord[] }) {
+ // console.log('data', data)
+ const columns: ColumnDef[] = [
+ {
+ header: 'Node',
+ accessorKey: 'name',
+ },
+ {
+ header: 'CPU Load',
+ accessorKey: 'stats.cpu',
+ cell: CellFormatter,
+ },
+ {
+ header: 'RAM',
+ accessorKey: 'stats.memPct',
+ cell: CellFormatter,
+ },
+ {
+ header: 'Disk Usage',
+ accessorKey: 'stats.diskPct',
+ cell: CellFormatter,
+ },
+ ]
+
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ return (
+
+
+
+ {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())}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results.
+
+
+ )}
+
+
+
+ )
+}
diff --git a/site/src/components/theme-provider.tsx b/site/src/components/theme-provider.tsx
new file mode 100644
index 0000000..02afb13
--- /dev/null
+++ b/site/src/components/theme-provider.tsx
@@ -0,0 +1,71 @@
+import { createContext, useContext, useEffect, useState } from 'react'
+
+type Theme = 'dark' | 'light' | 'system'
+
+type ThemeProviderProps = {
+ children: React.ReactNode
+ defaultTheme?: Theme
+ storageKey?: string
+}
+
+type ThemeProviderState = {
+ theme: Theme
+ setTheme: (theme: Theme) => void
+}
+
+const initialState: ThemeProviderState = {
+ theme: 'system',
+ setTheme: () => null,
+}
+
+const ThemeProviderContext = createContext(initialState)
+
+export function ThemeProvider({
+ children,
+ defaultTheme = 'system',
+ storageKey = 'vite-ui-theme',
+ ...props
+}: ThemeProviderProps) {
+ const [theme, setTheme] = useState(
+ () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
+ )
+
+ useEffect(() => {
+ const root = window.document.documentElement
+
+ root.classList.remove('light', 'dark')
+
+ if (theme === 'system') {
+ const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light'
+
+ root.classList.add(systemTheme)
+ return
+ }
+
+ root.classList.add(theme)
+ }, [theme])
+
+ const value = {
+ theme,
+ setTheme: (theme: Theme) => {
+ localStorage.setItem(storageKey, theme)
+ setTheme(theme)
+ },
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useTheme = () => {
+ const context = useContext(ThemeProviderContext)
+
+ if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider')
+
+ return context
+}
diff --git a/site/src/components/ui/button.tsx b/site/src/components/ui/button.tsx
new file mode 100644
index 0000000..d7e2ae7
--- /dev/null
+++ b/site/src/components/ui/button.tsx
@@ -0,0 +1,49 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+ outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-10 px-4 py-2',
+ sm: 'h-9 rounded-md px-3',
+ lg: 'h-11 rounded-md px-8',
+ icon: 'h-10 w-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'button'
+ return (
+
+ )
+ }
+)
+Button.displayName = 'Button'
+
+export { Button, buttonVariants }
diff --git a/site/src/components/ui/dropdown-menu.tsx b/site/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..769ff7a
--- /dev/null
+++ b/site/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,198 @@
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/site/src/components/ui/input.tsx b/site/src/components/ui/input.tsx
new file mode 100644
index 0000000..677d05f
--- /dev/null
+++ b/site/src/components/ui/input.tsx
@@ -0,0 +1,25 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Input.displayName = "Input"
+
+export { Input }
diff --git a/site/src/components/ui/label.tsx b/site/src/components/ui/label.tsx
new file mode 100644
index 0000000..683faa7
--- /dev/null
+++ b/site/src/components/ui/label.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/site/src/components/ui/table.tsx b/site/src/components/ui/table.tsx
new file mode 100644
index 0000000..7f3502f
--- /dev/null
+++ b/site/src/components/ui/table.tsx
@@ -0,0 +1,117 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Table.displayName = "Table"
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableHeader.displayName = "TableHeader"
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableBody.displayName = "TableBody"
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+))
+TableFooter.displayName = "TableFooter"
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableRow.displayName = "TableRow"
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+))
+TableHead.displayName = "TableHead"
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/site/src/components/user-auth-form.tsx b/site/src/components/user-auth-form.tsx
new file mode 100644
index 0000000..bee950f
--- /dev/null
+++ b/site/src/components/user-auth-form.tsx
@@ -0,0 +1,125 @@
+'use client'
+
+import * as React from 'react'
+// import { useSearchParams } from 'next/navigation'
+// import { zodResolver } from '@hookform/resolvers/zod'
+// import { signIn } from 'next-auth/react'
+// import { useForm } from 'react-hook-form'
+// import * as z from 'zod'
+
+import { cn } from '@/lib/utils'
+import { userAuthSchema } from '@/lib/validations/auth'
+import { buttonVariants } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+// import { toast } from '@/components/ui/use-toast'
+import { Github, LoaderCircle } from 'lucide-react'
+
+interface UserAuthFormProps extends React.HTMLAttributes {}
+
+type FormData = z.infer
+
+export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
+ const signIn = (s: string) => console.log(s)
+ const handleSubmit = (e: React.FormEvent) => {
+ // e.preventDefault()
+ signIn('github')
+ }
+
+ const errors = {
+ email: 'This field is required',
+ password: 'This field is required',
+ }
+
+ // const {
+ // register,
+ // handleSubmit,
+ // formState: { errors },
+ // } = useForm({
+ // resolver: zodResolver(userAuthSchema),
+ // })
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isGitHubLoading, setIsGitHubLoading] = React.useState(false)
+ // const searchParams = useSearchParams()
+
+ async function onSubmit(data: FormData) {
+ setIsLoading(true)
+
+ alert('do pb stuff')
+
+ // const signInResult = await signIn('email', {
+ // email: data.email.toLowerCase(),
+ // redirect: false,
+ // callbackUrl: searchParams?.get('from') || '/dashboard',
+ // })
+
+ setIsLoading(false)
+
+ if (!signInResult?.ok) {
+ alert('Your sign in request failed. Please try again.')
+ // return toast({
+ // title: 'Something went wrong.',
+ // description: 'Your sign in request failed. Please try again.',
+ // variant: 'destructive',
+ // })
+ }
+
+ // return toast({
+ // title: 'Check your email',
+ // description: 'We sent you a login link. Be sure to check your spam too.',
+ // })
+ }
+
+ return (
+
+
+
+
+
+
+
+ Or continue with
+
+
+
+
+ )
+}
diff --git a/site/src/index.css b/site/src/index.css
new file mode 100644
index 0000000..929b866
--- /dev/null
+++ b/site/src/index.css
@@ -0,0 +1,76 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 224 71.4% 4.1%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 224 71.4% 4.1%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 224 71.4% 4.1%;
+
+ --primary: 220.9 39.3% 11%;
+ --primary-foreground: 210 20% 98%;
+
+ --secondary: 220 14.3% 95.9%;
+ --secondary-foreground: 220.9 39.3% 11%;
+
+ --muted: 220 14.3% 95.9%;
+ --muted-foreground: 220 8.9% 46.1%;
+
+ --accent: 220 14.3% 95.9%;
+ --accent-foreground: 220.9 39.3% 11%;
+
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 20% 98%;
+
+ --border: 220 13% 91%;
+ --input: 220 13% 91%;
+ --ring: 224 71.4% 4.1%;
+
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 224 71.4% 4.1%;
+ --foreground: 210 20% 98%;
+
+ --card: 224 71.4% 4.1%;
+ --card-foreground: 210 20% 98%;
+
+ --popover: 224 71.4% 4.1%;
+ --popover-foreground: 210 20% 98%;
+
+ --primary: 210 20% 98%;
+ --primary-foreground: 220.9 39.3% 11%;
+
+ --secondary: 215 27.9% 16.9%;
+ --secondary-foreground: 210 20% 98%;
+
+ --muted: 215 27.9% 16.9%;
+ --muted-foreground: 217.9 10.6% 64.9%;
+
+ --accent: 215 27.9% 16.9%;
+ --accent-foreground: 210 20% 98%;
+
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 20% 98%;
+
+ --border: 215 27.9% 16.9%;
+ --input: 215 27.9% 16.9%;
+ --ring: 216 12.2% 83.9%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/site/src/lib/stores.ts b/site/src/lib/stores.ts
new file mode 100644
index 0000000..165c7dc
--- /dev/null
+++ b/site/src/lib/stores.ts
@@ -0,0 +1,3 @@
+import PocketBase from 'pocketbase'
+
+export const pb = new PocketBase('/')
diff --git a/site/src/lib/utils.ts b/site/src/lib/utils.ts
new file mode 100644
index 0000000..d084cca
--- /dev/null
+++ b/site/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/site/src/main.tsx b/site/src/main.tsx
new file mode 100644
index 0000000..47c29a2
--- /dev/null
+++ b/site/src/main.tsx
@@ -0,0 +1,45 @@
+import './index.css'
+import { render } from 'preact'
+import { Link, 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'
+import { pb } from './lib/stores.ts'
+import { ServerDetail } from './components/routes/server.tsx'
+
+// import { ModeToggle } from './components/mode-toggle.tsx'
+
+// const ls = localStorage.getItem('auth')
+// console.log('ls', ls)
+// @ts-ignore
+pb.authStore.storageKey = 'pb_admin_auth'
+
+console.log('pb.authStore', pb.authStore)
+
+const App = () => {pb.authStore.isValid ? : }
+
+const Main = () => (
+
+
+
+ {/*
+ Routes below are matched exclusively -
+ the first matched route gets rendered
+ */}
+
+
+
+
+
+ {/* Default route in a switch */}
+ 404: No such page!
+
+
+)
+
+render(, document.getElementById('app')!)
diff --git a/site/src/types.d.ts b/site/src/types.d.ts
new file mode 100644
index 0000000..9788f77
--- /dev/null
+++ b/site/src/types.d.ts
@@ -0,0 +1,16 @@
+import { RecordModel } from 'pocketbase'
+
+export interface SystemRecord extends RecordModel {
+ name: string
+ stats: SystemStats
+}
+
+export interface SystemStats {
+ cpu: number
+ disk: number
+ diskPct: number
+ diskUsed: number
+ mem: number
+ memPct: number
+ memUsed: number
+}
diff --git a/site/src/vite-env.d.ts b/site/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/site/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/site/tailwind.config.js b/site/tailwind.config.js
new file mode 100644
index 0000000..7cb7e37
--- /dev/null
+++ b/site/tailwind.config.js
@@ -0,0 +1,77 @@
+/** @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
diff --git a/site/tsconfig.app.json b/site/tsconfig.app.json
new file mode 100644
index 0000000..585f0ee
--- /dev/null
+++ b/site/tsconfig.app.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "baseUrl": ".",
+ "paths": {
+ "react": ["./node_modules/preact/compat/"],
+ "react-dom": ["./node_modules/preact/compat/"],
+ "@/*": ["./src/*"]
+ },
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/site/tsconfig.json b/site/tsconfig.json
new file mode 100644
index 0000000..a5b06bf
--- /dev/null
+++ b/site/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
+}
diff --git a/site/tsconfig.node.json b/site/tsconfig.node.json
new file mode 100644
index 0000000..3afdd6e
--- /dev/null
+++ b/site/tsconfig.node.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "noEmit": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/site/vite.config.ts b/site/vite.config.ts
new file mode 100644
index 0000000..53d3e35
--- /dev/null
+++ b/site/vite.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vite'
+import preact from '@preact/preset-vite'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [preact()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+})