mirror of
https://github.com/fankes/beszel.git
synced 2025-10-18 17:29:28 +08:00
login and other updates
This commit is contained in:
@@ -88,7 +88,6 @@ export default function ({ chartData }: { chartData: Record<string, number | str
|
||||
{Object.keys(chartConfig).map((key) => (
|
||||
<Area
|
||||
key={key}
|
||||
// isAnimationActive={false}
|
||||
animateNewValues={false}
|
||||
dataKey={key}
|
||||
type="monotone"
|
||||
|
@@ -51,10 +51,11 @@ export default function ({ chartData }: { chartData: { time: string; cpu: number
|
||||
/>
|
||||
<Area
|
||||
dataKey="cpu"
|
||||
type="monotone"
|
||||
type="natural"
|
||||
fill="var(--color-cpu)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-cpu)"
|
||||
animateNewValues={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
|
@@ -1,22 +1,24 @@
|
||||
import { UserAuthForm } from '@/components/user-auth-form'
|
||||
import { Logo } from './logo'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function () {
|
||||
useEffect(() => {
|
||||
document.title = 'Login / Qoma'
|
||||
}, [])
|
||||
return (
|
||||
<div className="relative h-screen grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div className="grid items-center">
|
||||
<div className="flex flex-col justify-center space-y-6 w-full px-4 max-w-[22em] mx-auto">
|
||||
<div className="relative h-screen grid lg:max-w-none lg:px-0">
|
||||
<div className="grid items-center py-12">
|
||||
<div className="grid gap-5 w-full px-4 max-w-[22em] mx-auto">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4">
|
||||
<Logo className="h-6 fill-foreground mx-auto" />
|
||||
<div className="sr-only">Qoma</div>
|
||||
<h1 className="mb-3">
|
||||
<Logo className="h-7 fill-foreground mx-auto" />
|
||||
<span className="sr-only">Qoma</span>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email to sign in to your account
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Please sign in to your account</p>
|
||||
</div>
|
||||
<UserAuthForm />
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
<p className="text-center text-sm opacity-70 hover:opacity-100 transition-opacity">
|
||||
{/* todo: add forgot password section to readme and link to section
|
||||
reset w/ command or link to pb reset */}
|
||||
<a
|
||||
@@ -28,12 +30,12 @@ export default function () {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-full bg-primary lg:block">
|
||||
{/* <div className="relative hidden h-full bg-primary lg:block">
|
||||
<img
|
||||
className="absolute inset-0 h-full w-full object-cover bg-primary"
|
||||
src="/penguin-and-egg.avif"
|
||||
></img>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@@ -1,48 +1,12 @@
|
||||
import { Suspense, lazy, useEffect } from 'react'
|
||||
import { $servers, pb } from '@/lib/stores'
|
||||
// import { DataTable } from '../server-table/data-table'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
|
||||
import { SystemRecord } from '@/types'
|
||||
import { updateServerList } from '@/lib/utils'
|
||||
|
||||
const DataTable = lazy(() => import('../server-table/data-table'))
|
||||
|
||||
export default function () {
|
||||
useEffect(() => {
|
||||
document.title = 'Qoma Dashboard'
|
||||
}, [])
|
||||
|
||||
useEffect(updateServerList, [])
|
||||
|
||||
useEffect(() => {
|
||||
pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
|
||||
const curServers = $servers.get()
|
||||
const newServers = []
|
||||
console.log('e', e)
|
||||
if (e.action === 'delete') {
|
||||
for (const server of curServers) {
|
||||
if (server.id !== e.record.id) {
|
||||
newServers.push(server)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let found = 0
|
||||
for (const server of curServers) {
|
||||
if (server.id === e.record.id) {
|
||||
found = newServers.push(e.record)
|
||||
} else {
|
||||
newServers.push(server)
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
newServers.push(e.record)
|
||||
}
|
||||
}
|
||||
$servers.set(newServers)
|
||||
})
|
||||
return () => {
|
||||
pb.collection('systems').unsubscribe('*')
|
||||
}
|
||||
document.title = 'Dashboard / Qoma'
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
@@ -71,7 +71,7 @@ export default function ServerDetail({ name }: { name: string }) {
|
||||
// console.log('sctats', records)
|
||||
setServerStats(records.items)
|
||||
})
|
||||
}, [server])
|
||||
}, [server, servers])
|
||||
|
||||
// get cpu data
|
||||
useEffect(() => {
|
||||
@@ -100,16 +100,9 @@ export default function ServerDetail({ name }: { name: string }) {
|
||||
}
|
||||
// console.log('running')
|
||||
const matchingServer = servers.find((s) => s.name === name) as SystemRecord
|
||||
|
||||
// console.log('found server', matchingServer)
|
||||
setServer(matchingServer)
|
||||
|
||||
console.log('found server', matchingServer)
|
||||
// pb.collection<SystemRecord>('systems')
|
||||
// .getOne(serverId)
|
||||
// .then((record) => {
|
||||
// setServer(record)
|
||||
// })
|
||||
|
||||
pb.collection<ContainerStatsRecord>('container_stats')
|
||||
.getList(1, 60, {
|
||||
filter: `system="${matchingServer.id}"`,
|
||||
|
@@ -62,6 +62,14 @@ import { $servers, pb, navigate } from '@/lib/stores'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { AddServerButton } from '../add-server'
|
||||
import { cn, copyToClipboard } from '@/lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogHeader,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
|
||||
const val = info.getValue() as number
|
||||
@@ -121,6 +129,7 @@ export default function () {
|
||||
style={{ marginBottom: '-1px' }}
|
||||
></span>
|
||||
<Button
|
||||
data-nolink
|
||||
variant={'ghost'}
|
||||
className="text-foreground/80 h-7 px-1.5 gap-1.5"
|
||||
onClick={() => copyToClipboard(info.getValue() as string)}
|
||||
@@ -156,17 +165,27 @@ export default function () {
|
||||
const { id, name, status, host } = row.original
|
||||
return (
|
||||
<div className={'flex justify-end items-center gap-1'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={'icon'}
|
||||
onClick={() => alert('notifications coming soon')}
|
||||
>
|
||||
<BellIcon className="h-[1.2em] w-[1.2em] pointer-events-none" />
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size={'icon'} aria-label="Notifications" data-nolink>
|
||||
<BellIcon className="h-[1.2em] w-[1.2em] pointer-events-none" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notifications</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
The agent must be running on the server to connect. Copy the{' '}
|
||||
<code className="bg-muted px-1 rounded-sm">docker-compose.yml</code> for the
|
||||
agent below.
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AlertDialog>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size={'icon'}>
|
||||
<Button variant="ghost" size={'icon'} data-nolink>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="w-5" />
|
||||
</Button>
|
||||
@@ -301,7 +320,7 @@ export default function () {
|
||||
})}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName !== 'BUTTON' && !target.hasAttribute('role')) {
|
||||
if (!target.closest('[data-nolink]') && e.currentTarget.contains(target)) {
|
||||
navigate(`/server/${row.original.name}`)
|
||||
}
|
||||
}}
|
||||
|
@@ -8,78 +8,88 @@ import * as React from 'react'
|
||||
// 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'
|
||||
import { Github, LoaderCircle, LogInIcon } from 'lucide-react'
|
||||
import { pb } from '@/lib/stores'
|
||||
import * as v from 'valibot'
|
||||
import { toast } from './ui/use-toast'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
const LoginSchema = v.object({
|
||||
email: v.pipe(v.string(), v.email('Invalid email address.')),
|
||||
password: v.pipe(v.string(), v.minLength(10, 'Password must be at least 10 characters long.')),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof userAuthSchema>
|
||||
// type LoginData = v.InferOutput<typeof LoginSchema> // { email: string; password: string }
|
||||
|
||||
export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
const signIn = (s: string) => console.log(s)
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
// e.preventDefault()
|
||||
signIn('github')
|
||||
}
|
||||
|
||||
const errors = {
|
||||
email: 'This field is required',
|
||||
password: 'This field is required',
|
||||
}
|
||||
|
||||
// const {
|
||||
// register,
|
||||
// handleSubmit,
|
||||
// formState: { errors },
|
||||
// } = useForm<FormData>({
|
||||
// resolver: zodResolver(userAuthSchema),
|
||||
// })
|
||||
export function UserAuthForm({ className, ...props }: { className?: string }) {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
const [isGitHubLoading, setIsGitHubLoading] = React.useState<boolean>(false)
|
||||
const [errors, setErrors] = React.useState<Record<string, string | undefined>>({})
|
||||
|
||||
// const searchParams = useSearchParams()
|
||||
|
||||
async function onSubmit(data: FormData) {
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
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',
|
||||
// })
|
||||
try {
|
||||
const formData = new FormData(e.target as HTMLFormElement)
|
||||
const data = Object.fromEntries(formData) as Record<string, any>
|
||||
const result = v.safeParse(LoginSchema, data)
|
||||
if (!result.success) {
|
||||
let errors = {}
|
||||
for (const issue of result.issues) {
|
||||
// @ts-ignore
|
||||
errors[issue.path[0].key] = issue.message
|
||||
}
|
||||
setErrors(errors)
|
||||
return
|
||||
}
|
||||
const { email, password } = result.output
|
||||
let firstRun = true
|
||||
if (firstRun) {
|
||||
await pb.admins.create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
})
|
||||
await pb.admins.authWithPassword(email, password)
|
||||
} else {
|
||||
await pb.admins.authWithPassword(email, password)
|
||||
}
|
||||
} catch (e) {
|
||||
return toast({
|
||||
title: 'Login attempt failed',
|
||||
description: 'Please check your credentials and try again',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// return toast({
|
||||
// title: 'Check your email',
|
||||
// description: 'We sent you a login link. Be sure to check your spam too.',
|
||||
// })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-6', className)} {...props}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-2.5">
|
||||
<div className="grid gap-1">
|
||||
<Label className="sr-only" htmlFor="email">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
@@ -87,11 +97,30 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
autoCorrect="off"
|
||||
disabled={isLoading || isGitHubLoading}
|
||||
/>
|
||||
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email.message}</p>}
|
||||
{errors?.email && <p className="px-1 text-xs text-red-600">{errors.email}</p>}
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label className="sr-only" htmlFor="email">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="pass"
|
||||
name="password"
|
||||
placeholder="password"
|
||||
required
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading || isGitHubLoading}
|
||||
/>
|
||||
{errors?.password && <p className="px-1 text-xs text-red-600">{errors.password}</p>}
|
||||
</div>
|
||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||
{isLoading && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Sign In with Email
|
||||
{isLoading ? (
|
||||
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogInIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -103,23 +132,35 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: 'outline' }))}
|
||||
onClick={() => {
|
||||
localStorage.setItem('auth', 'true')
|
||||
setIsGitHubLoading(true)
|
||||
signIn('github')
|
||||
}}
|
||||
disabled={isLoading || isGitHubLoading}
|
||||
>
|
||||
{isGitHubLoading ? (
|
||||
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
)}{' '}
|
||||
Github
|
||||
</button>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(buttonVariants({ variant: 'outline' }))}
|
||||
// onClick={async () => {
|
||||
// setIsGitHubLoading(true)
|
||||
// do stuff
|
||||
// setIsGitHubLoading(false)
|
||||
// }}
|
||||
disabled={isLoading || isGitHubLoading}
|
||||
>
|
||||
{isGitHubLoading ? (
|
||||
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
)}{' '}
|
||||
Github
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>OAuth support coming soon</DialogTitle>
|
||||
<DialogDescription>
|
||||
OAuth / OpenID with all major providers should be available at 1.0.0.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@@ -24,6 +24,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './components/ui/dropdown-menu.tsx'
|
||||
import { SystemRecord } from './types'
|
||||
|
||||
const ServerDetail = lazy(() => import('./components/routes/server.tsx'))
|
||||
const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
|
||||
@@ -37,27 +38,53 @@ const App = () => {
|
||||
// get servers
|
||||
useEffect(updateServerList, [])
|
||||
|
||||
useEffect(() => {
|
||||
pb.collection<SystemRecord>('systems').subscribe('*', (e) => {
|
||||
const curServers = $servers.get()
|
||||
const newServers = []
|
||||
// console.log('e', e)
|
||||
if (e.action === 'delete') {
|
||||
for (const server of curServers) {
|
||||
if (server.id !== e.record.id) {
|
||||
newServers.push(server)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let found = 0
|
||||
for (const server of curServers) {
|
||||
if (server.id === e.record.id) {
|
||||
found = newServers.push(e.record)
|
||||
} else {
|
||||
newServers.push(server)
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
newServers.push(e.record)
|
||||
}
|
||||
}
|
||||
$servers.set(newServers)
|
||||
})
|
||||
return () => {
|
||||
pb.collection('systems').unsubscribe('*')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// update favicon
|
||||
useEffect(() => {
|
||||
if (!authenticated || !servers.length) {
|
||||
console.log('no auth favicon')
|
||||
updateFavicon('/favicon.svg')
|
||||
} else {
|
||||
const cleanup = () => {
|
||||
updateFavicon('/favicon.svg')
|
||||
}
|
||||
let up = false
|
||||
for (const server of servers) {
|
||||
if (server.status === 'down') {
|
||||
console.log('down', server)
|
||||
updateFavicon('/favicon-red.svg')
|
||||
return cleanup
|
||||
return () => updateFavicon('/favicon.svg')
|
||||
} else if (server.status === 'up') {
|
||||
up = true
|
||||
}
|
||||
}
|
||||
updateFavicon(up ? '/favicon-green.svg' : '/favicon.svg')
|
||||
return cleanup
|
||||
return () => updateFavicon('/favicon.svg')
|
||||
}
|
||||
return () => {
|
||||
updateFavicon('/favicon.svg')
|
||||
@@ -155,7 +182,6 @@ const Layout = () => {
|
||||
<div className="container mb-14 relative">
|
||||
<App />
|
||||
<CommandPalette />
|
||||
<Toaster />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
@@ -167,6 +193,9 @@ ReactDOM.createRoot(document.getElementById('app')!).render(
|
||||
<Suspense>
|
||||
<Layout />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<Toaster />
|
||||
</Suspense>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
Reference in New Issue
Block a user