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) => ( {Object.keys(chartConfig).map((key) => (
<Area <Area
key={key} key={key}
// isAnimationActive={false}
animateNewValues={false} animateNewValues={false}
dataKey={key} dataKey={key}
type="monotone" type="monotone"

View File

@@ -51,10 +51,11 @@ export default function ({ chartData }: { chartData: { time: string; cpu: number
/> />
<Area <Area
dataKey="cpu" dataKey="cpu"
type="monotone" type="natural"
fill="var(--color-cpu)" fill="var(--color-cpu)"
fillOpacity={0.4} fillOpacity={0.4}
stroke="var(--color-cpu)" stroke="var(--color-cpu)"
animateNewValues={false}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>

View File

@@ -1,22 +1,24 @@
import { UserAuthForm } from '@/components/user-auth-form' import { UserAuthForm } from '@/components/user-auth-form'
import { Logo } from './logo' import { Logo } from './logo'
import { useEffect } from 'react'
export default function () { export default function () {
useEffect(() => {
document.title = 'Login / Qoma'
}, [])
return ( return (
<div className="relative h-screen grid lg:max-w-none lg:grid-cols-2 lg:px-0"> <div className="relative h-screen grid lg:max-w-none lg:px-0">
<div className="grid items-center"> <div className="grid items-center py-12">
<div className="flex flex-col justify-center space-y-6 w-full px-4 max-w-[22em] mx-auto"> <div className="grid gap-5 w-full px-4 max-w-[22em] mx-auto">
<div className="text-center"> <div className="text-center">
<h1 className="mb-4"> <h1 className="mb-3">
<Logo className="h-6 fill-foreground mx-auto" /> <Logo className="h-7 fill-foreground mx-auto" />
<div className="sr-only">Qoma</div> <span className="sr-only">Qoma</span>
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Please sign in to your account</p>
Enter your email to sign in to your account
</p>
</div> </div>
<UserAuthForm /> <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 {/* todo: add forgot password section to readme and link to section
reset w/ command or link to pb reset */} reset w/ command or link to pb reset */}
<a <a
@@ -28,12 +30,12 @@ export default function () {
</p> </p>
</div> </div>
</div> </div>
<div className="relative hidden h-full bg-primary lg:block"> {/* <div className="relative hidden h-full bg-primary lg:block">
<img <img
className="absolute inset-0 h-full w-full object-cover bg-primary" className="absolute inset-0 h-full w-full object-cover bg-primary"
src="/penguin-and-egg.avif" src="/penguin-and-egg.avif"
></img> ></img>
</div> </div> */}
</div> </div>
) )
} }

View File

@@ -1,48 +1,12 @@
import { Suspense, lazy, useEffect } from 'react' import { Suspense, lazy, useEffect } from 'react'
import { $servers, pb } from '@/lib/stores'
// import { DataTable } from '../server-table/data-table' // import { DataTable } from '../server-table/data-table'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card' 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')) const DataTable = lazy(() => import('../server-table/data-table'))
export default function () { export default function () {
useEffect(() => { useEffect(() => {
document.title = 'Qoma Dashboard' document.title = 'Dashboard / Qoma'
}, [])
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('*')
}
}, []) }, [])
return ( return (

View File

@@ -71,7 +71,7 @@ export default function ServerDetail({ name }: { name: string }) {
// console.log('sctats', records) // console.log('sctats', records)
setServerStats(records.items) setServerStats(records.items)
}) })
}, [server]) }, [server, servers])
// get cpu data // get cpu data
useEffect(() => { useEffect(() => {
@@ -100,16 +100,9 @@ export default function ServerDetail({ name }: { name: string }) {
} }
// console.log('running') // console.log('running')
const matchingServer = servers.find((s) => s.name === name) as SystemRecord const matchingServer = servers.find((s) => s.name === name) as SystemRecord
// console.log('found server', matchingServer)
setServer(matchingServer) setServer(matchingServer)
console.log('found server', matchingServer)
// pb.collection<SystemRecord>('systems')
// .getOne(serverId)
// .then((record) => {
// setServer(record)
// })
pb.collection<ContainerStatsRecord>('container_stats') pb.collection<ContainerStatsRecord>('container_stats')
.getList(1, 60, { .getList(1, 60, {
filter: `system="${matchingServer.id}"`, filter: `system="${matchingServer.id}"`,

View File

@@ -62,6 +62,14 @@ import { $servers, pb, navigate } from '@/lib/stores'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { AddServerButton } from '../add-server' import { AddServerButton } from '../add-server'
import { cn, copyToClipboard } from '@/lib/utils' import { cn, copyToClipboard } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogTrigger,
DialogDescription,
DialogTitle,
DialogHeader,
} from '@/components/ui/dialog'
function CellFormatter(info: CellContext<SystemRecord, unknown>) { function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const val = info.getValue() as number const val = info.getValue() as number
@@ -121,6 +129,7 @@ export default function () {
style={{ marginBottom: '-1px' }} style={{ marginBottom: '-1px' }}
></span> ></span>
<Button <Button
data-nolink
variant={'ghost'} variant={'ghost'}
className="text-foreground/80 h-7 px-1.5 gap-1.5" className="text-foreground/80 h-7 px-1.5 gap-1.5"
onClick={() => copyToClipboard(info.getValue() as string)} onClick={() => copyToClipboard(info.getValue() as string)}
@@ -156,17 +165,27 @@ export default function () {
const { id, name, status, host } = row.original const { id, name, status, host } = row.original
return ( return (
<div className={'flex justify-end items-center gap-1'}> <div className={'flex justify-end items-center gap-1'}>
<Button <Dialog>
variant="ghost" <DialogTrigger asChild>
size={'icon'} <Button variant="ghost" size={'icon'} aria-label="Notifications" data-nolink>
onClick={() => alert('notifications coming soon')} <BellIcon className="h-[1.2em] w-[1.2em] pointer-events-none" />
> </Button>
<BellIcon className="h-[1.2em] w-[1.2em] pointer-events-none" /> </DialogTrigger>
</Button> <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> <AlertDialog>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size={'icon'}> <Button variant="ghost" size={'icon'} data-nolink>
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
<MoreHorizontal className="w-5" /> <MoreHorizontal className="w-5" />
</Button> </Button>
@@ -301,7 +320,7 @@ export default function () {
})} })}
onClick={(e) => { onClick={(e) => {
const target = e.target as HTMLElement 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}`) navigate(`/server/${row.original.name}`)
} }
}} }}

View File

@@ -8,78 +8,88 @@ import * as React from 'react'
// import * as z from 'zod' // import * as z from 'zod'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { userAuthSchema } from '@/lib/validations/auth'
import { buttonVariants } from '@/components/ui/button' import { buttonVariants } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
// import { toast } from '@/components/ui/use-toast' // 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) { export function UserAuthForm({ className, ...props }: { className?: string }) {
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),
// })
const [isLoading, setIsLoading] = React.useState<boolean>(false) const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [isGitHubLoading, setIsGitHubLoading] = React.useState<boolean>(false) const [isGitHubLoading, setIsGitHubLoading] = React.useState<boolean>(false)
const [errors, setErrors] = React.useState<Record<string, string | undefined>>({})
// const searchParams = useSearchParams() // const searchParams = useSearchParams()
async function onSubmit(data: FormData) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true) setIsLoading(true)
try {
alert('do pb stuff') const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Record<string, any>
// const signInResult = await signIn('email', { const result = v.safeParse(LoginSchema, data)
// email: data.email.toLowerCase(), if (!result.success) {
// redirect: false, let errors = {}
// callbackUrl: searchParams?.get('from') || '/dashboard', for (const issue of result.issues) {
// }) // @ts-ignore
errors[issue.path[0].key] = issue.message
setIsLoading(false) }
setErrors(errors)
if (!signInResult?.ok) { return
alert('Your sign in request failed. Please try again.') }
// return toast({ const { email, password } = result.output
// title: 'Something went wrong.', let firstRun = true
// description: 'Your sign in request failed. Please try again.', if (firstRun) {
// variant: 'destructive', 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 ( return (
<div className={cn('grid gap-6', className)} {...props}> <div className={cn('grid gap-6', className)} {...props}>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid gap-2"> <div className="grid gap-2.5">
<div className="grid gap-1"> <div className="grid gap-1">
<Label className="sr-only" htmlFor="email"> <Label className="sr-only" htmlFor="email">
Email Email
</Label> </Label>
<Input <Input
id="email" id="email"
name="email"
required
placeholder="name@example.com" placeholder="name@example.com"
type="email" type="email"
autoCapitalize="none" autoCapitalize="none"
@@ -87,11 +97,30 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
autoCorrect="off" autoCorrect="off"
disabled={isLoading || isGitHubLoading} 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> </div>
<button className={cn(buttonVariants())} disabled={isLoading}> <button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />} {isLoading ? (
Sign In with Email <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogInIcon className="mr-2 h-4 w-4" />
)}
Sign In
</button> </button>
</div> </div>
</form> </form>
@@ -103,23 +132,35 @@ export function UserAuthForm({ className, ...props }: UserAuthFormProps) {
<span className="bg-background px-2 text-muted-foreground">Or continue with</span> <span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div> </div>
</div> </div>
<button <Dialog>
type="button" <DialogTrigger asChild>
className={cn(buttonVariants({ variant: 'outline' }))} <button
onClick={() => { type="button"
localStorage.setItem('auth', 'true') className={cn(buttonVariants({ variant: 'outline' }))}
setIsGitHubLoading(true) // onClick={async () => {
signIn('github') // setIsGitHubLoading(true)
}} // do stuff
disabled={isLoading || isGitHubLoading} // setIsGitHubLoading(false)
> // }}
{isGitHubLoading ? ( disabled={isLoading || isGitHubLoading}
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" /> >
) : ( {isGitHubLoading ? (
<Github className="mr-2 h-4 w-4" /> <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
)}{' '} ) : (
Github <Github className="mr-2 h-4 w-4" />
</button> )}{' '}
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> </div>
) )
} }

View File

@@ -24,6 +24,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from './components/ui/dropdown-menu.tsx' } from './components/ui/dropdown-menu.tsx'
import { SystemRecord } from './types'
const ServerDetail = lazy(() => import('./components/routes/server.tsx')) const ServerDetail = lazy(() => import('./components/routes/server.tsx'))
const CommandPalette = lazy(() => import('./components/command-palette.tsx')) const CommandPalette = lazy(() => import('./components/command-palette.tsx'))
@@ -37,27 +38,53 @@ const App = () => {
// get servers // get servers
useEffect(updateServerList, []) 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 // update favicon
useEffect(() => { useEffect(() => {
if (!authenticated || !servers.length) { if (!authenticated || !servers.length) {
console.log('no auth favicon')
updateFavicon('/favicon.svg') updateFavicon('/favicon.svg')
} else { } else {
const cleanup = () => {
updateFavicon('/favicon.svg')
}
let up = false let up = false
for (const server of servers) { for (const server of servers) {
if (server.status === 'down') { if (server.status === 'down') {
console.log('down', server)
updateFavicon('/favicon-red.svg') updateFavicon('/favicon-red.svg')
return cleanup return () => updateFavicon('/favicon.svg')
} else if (server.status === 'up') { } else if (server.status === 'up') {
up = true up = true
} }
} }
updateFavicon(up ? '/favicon-green.svg' : '/favicon.svg') updateFavicon(up ? '/favicon-green.svg' : '/favicon.svg')
return cleanup return () => updateFavicon('/favicon.svg')
} }
return () => { return () => {
updateFavicon('/favicon.svg') updateFavicon('/favicon.svg')
@@ -155,7 +182,6 @@ const Layout = () => {
<div className="container mb-14 relative"> <div className="container mb-14 relative">
<App /> <App />
<CommandPalette /> <CommandPalette />
<Toaster />
</div> </div>
</> </>
) )
@@ -167,6 +193,9 @@ ReactDOM.createRoot(document.getElementById('app')!).render(
<Suspense> <Suspense>
<Layout /> <Layout />
</Suspense> </Suspense>
<Suspense>
<Toaster />
</Suspense>
</ThemeProvider> </ThemeProvider>
</React.StrictMode> </React.StrictMode>
) )