login and other updates

This commit is contained in:
Henry Dollman
2024-07-13 16:25:27 -04:00
parent 86cfa5079e
commit 357e3ad5d7
8 changed files with 193 additions and 145 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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 (

View File

@@ -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}"`,

View File

@@ -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}`)
}
}}

View File

@@ -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>
)
}

View File

@@ -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>
)