feat(tabby-ui): add cluster information page and team management page (#997)
* feat(tabby-ui): move swagger API from sidebar to header * feat(tabby-ui): extract /workers page * feat(tabby-ui): add reset button for registeration token * refactor: extract <SlackDialog /> * feat(tabby-ui): display authToken in dashboard * add team management page * extract invitation-table * fix lint * rename /workers -> /cluster * cleanup team management page * cleanup * fix * fix link title * fix title * fix nextjs build * fix lint * fix missing window error * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>r0.7
parent
fbceaafbc9
commit
fc93cf80b4
|
|
@ -0,0 +1,90 @@
|
|||
'use client'
|
||||
|
||||
import { graphql } from '@/lib/gql/generates'
|
||||
import { WorkerKind } from '@/lib/gql/generates/graphql'
|
||||
import { useHealth } from '@/lib/hooks/use-health'
|
||||
import { useWorkers } from '@/lib/hooks/use-workers'
|
||||
import { useAuthenticatedGraphQLQuery, useGraphQLForm } from '@/lib/tabby/gql'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { IconRefresh } from '@/components/ui/icons'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
|
||||
import WorkerCard from './worker-card'
|
||||
|
||||
const getRegistrationTokenDocument = graphql(/* GraphQL */ `
|
||||
query GetRegistrationToken {
|
||||
registrationToken
|
||||
}
|
||||
`)
|
||||
|
||||
const resetRegistrationTokenDocument = graphql(/* GraphQL */ `
|
||||
mutation ResetRegistrationToken {
|
||||
resetRegistrationToken
|
||||
}
|
||||
`)
|
||||
|
||||
export default function Workers() {
|
||||
const { data: healthInfo } = useHealth()
|
||||
const workers = useWorkers()
|
||||
const { data: registrationTokenRes, mutate } = useAuthenticatedGraphQLQuery(
|
||||
getRegistrationTokenDocument
|
||||
)
|
||||
|
||||
const { onSubmit: resetRegistrationToken } = useGraphQLForm(
|
||||
resetRegistrationTokenDocument,
|
||||
{
|
||||
onSuccess: () => mutate()
|
||||
}
|
||||
)
|
||||
|
||||
if (!healthInfo) return
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3 p-4 lg:p-16">
|
||||
{!!registrationTokenRes?.registrationToken && (
|
||||
<div className="flex items-center gap-1">
|
||||
Registeration token:
|
||||
<code className="rounded-lg text-sm text-red-600">
|
||||
{registrationTokenRes.registrationToken}
|
||||
</code>
|
||||
<Button
|
||||
title="Reset"
|
||||
size="icon"
|
||||
variant="hover-destructive"
|
||||
onClick={() => resetRegistrationToken()}
|
||||
>
|
||||
<IconRefresh />
|
||||
</Button>
|
||||
<CopyButton value={registrationTokenRes.registrationToken} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:flex-wrap">
|
||||
{!!workers?.[WorkerKind.Completion] && (
|
||||
<>
|
||||
{workers[WorkerKind.Completion].map((worker, i) => {
|
||||
return <WorkerCard key={i} {...worker} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{!!workers?.[WorkerKind.Chat] && (
|
||||
<>
|
||||
{workers[WorkerKind.Chat].map((worker, i) => {
|
||||
return <WorkerCard key={i} {...worker} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<WorkerCard
|
||||
addr="localhost"
|
||||
name="Code Search Index"
|
||||
kind="INDEX"
|
||||
arch=""
|
||||
device={healthInfo.device}
|
||||
cudaDevices={healthInfo.cuda_devices}
|
||||
cpuCount={healthInfo.cpu_count}
|
||||
cpuInfo={healthInfo.cpu_info}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Metadata } from 'next'
|
||||
|
||||
import ClusterInfo from './components/cluster'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cluster Information'
|
||||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
return <ClusterInfo />
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@ import Link from 'next/link'
|
|||
import { usePathname } from 'next/navigation'
|
||||
import { cva } from 'class-variance-authority'
|
||||
|
||||
import { useSession } from '@/lib/tabby/auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IconHome, IconNetwork, IconUsers } from '@/components/ui/icons'
|
||||
import UserPanel from '@/components/user-panel'
|
||||
|
||||
export interface SidebarProps {
|
||||
|
|
@ -13,6 +15,8 @@ export interface SidebarProps {
|
|||
}
|
||||
|
||||
export default function Sidebar({ children, className }: SidebarProps) {
|
||||
const { data: session } = useSession()
|
||||
const isAdmin = session?.isAdmin || false
|
||||
return (
|
||||
<div
|
||||
className={cn('grid overflow-hidden lg:grid-cols-[280px_1fr]', className)}
|
||||
|
|
@ -23,45 +27,18 @@ export default function Sidebar({ children, className }: SidebarProps) {
|
|||
<div className="flex-1">
|
||||
<nav className="grid items-start gap-4 px-4 text-sm font-medium">
|
||||
<SidebarButton href="/">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className=" h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
Home
|
||||
</SidebarButton>
|
||||
<SidebarButton href="/swagger">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className=" h-4 w-4"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||
<path d="M6 8h2" />
|
||||
<path d="M6 12h2" />
|
||||
<path d="M16 8h2" />
|
||||
<path d="M16 12h2" />
|
||||
</svg>
|
||||
Swagger
|
||||
<IconHome /> Home
|
||||
</SidebarButton>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<SidebarButton href="/cluster">
|
||||
<IconNetwork /> Cluster Information
|
||||
</SidebarButton>
|
||||
<SidebarButton href="/team">
|
||||
<IconUsers /> Team Management
|
||||
</SidebarButton>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { Header } from '@/components/header'
|
|||
import Sidebar from './components/sidebar'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Dashboard'
|
||||
title: {
|
||||
default: 'Home',
|
||||
template: `Tabby - %s`
|
||||
}
|
||||
}
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
|
|
|
|||
|
|
@ -1,100 +1,38 @@
|
|||
'use client'
|
||||
|
||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||
|
||||
import { graphql } from '@/lib/gql/generates'
|
||||
import { WorkerKind } from '@/lib/gql/generates/graphql'
|
||||
import { useHealth } from '@/lib/hooks/use-health'
|
||||
import { useWorkers } from '@/lib/hooks/use-workers'
|
||||
import { useSession } from '@/lib/tabby/auth'
|
||||
import { useAuthenticatedGraphQLQuery } from '@/lib/tabby/gql'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { IconSlack } from '@/components/ui/icons'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
|
||||
import WorkerCard from './components/worker-card'
|
||||
|
||||
const COMMUNITY_DIALOG_SHOWN_KEY = 'community-dialog-shown'
|
||||
import SlackDialog from '@/components/slack-dialog'
|
||||
|
||||
export default function Home() {
|
||||
const { status } = useSession()
|
||||
const [open, setOpen] = useState(false)
|
||||
useEffect(() => {
|
||||
if (status !== 'authenticated') return
|
||||
|
||||
if (!localStorage.getItem(COMMUNITY_DIALOG_SHOWN_KEY)) {
|
||||
setOpen(true)
|
||||
localStorage.setItem(COMMUNITY_DIALOG_SHOWN_KEY, 'true')
|
||||
}
|
||||
}, [status])
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-16">
|
||||
<MainPanel />
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="gap-3">
|
||||
<DialogTitle>Join the Tabby community</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect with other contributors building Tabby. Share knowledge,
|
||||
get help, and contribute to the open-source project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://join.slack.com/t/tabbycommunity/shared_invite/zt-1xeiddizp-bciR2RtFTaJ37RBxr8VxpA"
|
||||
className={buttonVariants()}
|
||||
>
|
||||
<IconSlack className="-ml-2 h-8 w-8" />
|
||||
Join us on Slack
|
||||
</a>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SlackDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LinkProps {
|
||||
href: string
|
||||
}
|
||||
|
||||
function Link({ href, children }: PropsWithChildren<LinkProps>) {
|
||||
return (
|
||||
<a target="_blank" href={href} className="underline">
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function toBadgeString(str: string) {
|
||||
return encodeURIComponent(str.replaceAll('-', '--'))
|
||||
}
|
||||
|
||||
const getRegistrationTokenDocument = graphql(/* GraphQL */ `
|
||||
query GetRegistrationToken {
|
||||
registrationToken
|
||||
const meQuery = graphql(/* GraphQL */ `
|
||||
query MeQuery {
|
||||
me {
|
||||
authToken
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
function MainPanel() {
|
||||
const { data: healthInfo } = useHealth()
|
||||
const workers = useWorkers(healthInfo)
|
||||
const { data: registrationTokenRes } = useAuthenticatedGraphQLQuery(
|
||||
getRegistrationTokenDocument
|
||||
)
|
||||
const { data } = useAuthenticatedGraphQLQuery(meQuery)
|
||||
|
||||
if (!healthInfo) return
|
||||
if (!healthInfo || !data) return
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
|
|
@ -115,47 +53,24 @@ function MainPanel() {
|
|||
</a>
|
||||
</span>
|
||||
<Separator />
|
||||
|
||||
<div className="mt-4 rounded-lg bg-zinc-100 p-4 dark:bg-zinc-800">
|
||||
<span className="font-bold">Workers</span>
|
||||
|
||||
{!!registrationTokenRes?.registrationToken && (
|
||||
<div className="flex items-center gap-1">
|
||||
Registeration token:{' '}
|
||||
<span className="rounded-lg text-sm text-red-600">
|
||||
{registrationTokenRes.registrationToken}
|
||||
</span>
|
||||
<CopyButton value={registrationTokenRes.registrationToken} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:flex-wrap">
|
||||
{!!workers?.[WorkerKind.Completion] && (
|
||||
<>
|
||||
{workers[WorkerKind.Completion].map((worker, i) => {
|
||||
return <WorkerCard key={i} {...worker} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{!!workers?.[WorkerKind.Chat] && (
|
||||
<>
|
||||
{workers[WorkerKind.Chat].map((worker, i) => {
|
||||
return <WorkerCard key={i} {...worker} />
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<WorkerCard
|
||||
addr="localhost"
|
||||
name="Code Search Index"
|
||||
kind="INDEX"
|
||||
arch=""
|
||||
device={healthInfo.device}
|
||||
cudaDevices={healthInfo.cuda_devices}
|
||||
cpuCount={healthInfo.cpu_count}
|
||||
cpuInfo={healthInfo.cpu_info}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">Token:</span>
|
||||
<code className="rounded-lg text-sm text-red-600">
|
||||
{data.me.authToken}
|
||||
</code>
|
||||
<CopyButton value={data.me.authToken} />
|
||||
</div>
|
||||
<p>
|
||||
Use credentials above for IDE extensions / plugins authentication, see{' '}
|
||||
<a
|
||||
className="underline"
|
||||
target="_blank"
|
||||
href="https://tabby.tabbyml.com/docs/extensions/configurations#server"
|
||||
>
|
||||
configurations
|
||||
</a>{' '}
|
||||
for details
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { graphql } from '@/lib/gql/generates'
|
||||
import { useGraphQLForm } from '@/lib/tabby/gql'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const createInvitation = graphql(/* GraphQL */ `
|
||||
mutation CreateInvitation($email: String!) {
|
||||
createInvitation(email: $email)
|
||||
}
|
||||
`)
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email('Invalid email address')
|
||||
})
|
||||
|
||||
export default function CreateInvitationForm({
|
||||
onCreated
|
||||
}: {
|
||||
onCreated: () => void
|
||||
}) {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema)
|
||||
})
|
||||
|
||||
const { isSubmitting } = form.formState
|
||||
const { onSubmit } = useGraphQLForm(createInvitation, {
|
||||
onSuccess: () => {
|
||||
form.reset({ email: '' })
|
||||
onCreated()
|
||||
},
|
||||
onError: (path, message) => form.setError(path as any, { message })
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<form
|
||||
className="flex w-full items-center gap-2"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
Invite
|
||||
</Button>
|
||||
</form>
|
||||
<FormMessage className="text-center" />
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
'use client'
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import moment from 'moment'
|
||||
|
||||
import { graphql } from '@/lib/gql/generates'
|
||||
import { useAuthenticatedGraphQLQuery, useGraphQLForm } from '@/lib/tabby/gql'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { IconTrash } from '@/components/ui/icons'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui/table'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
|
||||
import CreateInvitationForm from './create-invitation-form'
|
||||
|
||||
const listInvitations = graphql(/* GraphQL */ `
|
||||
query ListInvitations {
|
||||
invitations {
|
||||
id
|
||||
email
|
||||
code
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
const deleteInvitationMutation = graphql(/* GraphQL */ `
|
||||
mutation DeleteInvitation($id: Int!) {
|
||||
deleteInvitation(id: $id)
|
||||
}
|
||||
`)
|
||||
|
||||
export default function InvitationTable() {
|
||||
const { data, mutate } = useAuthenticatedGraphQLQuery(listInvitations)
|
||||
const invitations = data?.invitations
|
||||
const [origin, setOrigin] = useState('')
|
||||
useEffect(() => {
|
||||
setOrigin(new URL(window.location.href).origin)
|
||||
}, [])
|
||||
|
||||
const { onSubmit: deleteInvitation } = useGraphQLForm(
|
||||
deleteInvitationMutation,
|
||||
{
|
||||
onSuccess: () => mutate()
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
invitations && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Invitee</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invitations.map((x, i) => {
|
||||
const link = `${origin}/auth/signup?invitationCode=${x.code}`
|
||||
return (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="w-[300px] font-medium">
|
||||
{x.email}
|
||||
</TableCell>
|
||||
<TableCell>{moment.utc(x.createdAt).fromNow()}</TableCell>
|
||||
<TableCell className="flex items-center">
|
||||
<CopyButton value={link} />
|
||||
<Button
|
||||
size="icon"
|
||||
variant="hover-destructive"
|
||||
onClick={() => deleteInvitation({ id: x.id })}
|
||||
>
|
||||
<IconTrash />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow>
|
||||
<TableCell className="p-2">
|
||||
<CreateInvitationForm onCreated={() => mutate()} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
'use client'
|
||||
|
||||
import { CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
import InvitationTable from './invitation-table'
|
||||
|
||||
export default function Team() {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<CardHeader>
|
||||
<CardTitle>Invites</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<InvitationTable />
|
||||
</CardContent>
|
||||
</div>
|
||||
<div>
|
||||
<CardHeader>
|
||||
<CardTitle>Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4"></CardContent>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { Metadata } from 'next'
|
||||
|
||||
import Team from './components/team'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Team Management'
|
||||
}
|
||||
|
||||
export default function IndexPage() {
|
||||
return (
|
||||
<div className="p-4 lg:p-16">
|
||||
<Team />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { Metadata } from 'next'
|
||||
|
||||
import { Header } from '@/components/header'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'API'
|
||||
}
|
||||
|
|
@ -7,5 +9,10 @@ export const metadata: Metadata = {
|
|||
const serverUrl = process.env.NEXT_PUBLIC_TABBY_SERVER_URL || ''
|
||||
|
||||
export default function IndexPage() {
|
||||
return <iframe className="grow" src={`${serverUrl}/swagger-ui`} />
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<iframe className="grow" src={`${serverUrl}/swagger-ui`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ export function Header() {
|
|||
useAuthenticatedSession()
|
||||
|
||||
const { data } = useHealth()
|
||||
const workers = useWorkers(data)
|
||||
const workers = useWorkers()
|
||||
const isChatEnabled = has(workers, WorkerKind.Chat)
|
||||
const version = data?.version?.git_describe
|
||||
const { data: latestRelease } = useLatestRelease()
|
||||
|
|
@ -34,6 +34,9 @@ export function Header() {
|
|||
<Link href="/" className={cn(buttonVariants({ variant: 'link' }))}>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link href="/api" className={cn(buttonVariants({ variant: 'link' }))}>
|
||||
API
|
||||
</Link>
|
||||
{isChatEnabled && (
|
||||
<Link
|
||||
href="/playground"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useSession } from '@/lib/tabby/auth'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { IconSlack } from '@/components/ui/icons'
|
||||
|
||||
const COMMUNITY_DIALOG_SHOWN_KEY = 'community-dialog-shown'
|
||||
|
||||
export default function SlackDialog() {
|
||||
const { status } = useSession()
|
||||
const [open, setOpen] = useState(false)
|
||||
useEffect(() => {
|
||||
if (status !== 'authenticated') return
|
||||
|
||||
if (!localStorage.getItem(COMMUNITY_DIALOG_SHOWN_KEY)) {
|
||||
setOpen(true)
|
||||
localStorage.setItem(COMMUNITY_DIALOG_SHOWN_KEY, 'true')
|
||||
}
|
||||
}, [status])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="gap-3">
|
||||
<DialogTitle>Join the Tabby community</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect with other contributors building Tabby. Share knowledge, get
|
||||
help, and contribute to the open-source project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://join.slack.com/t/tabbycommunity/shared_invite/zt-1xeiddizp-bciR2RtFTaJ37RBxr8VxpA"
|
||||
className={buttonVariants()}
|
||||
>
|
||||
<IconSlack className="-ml-2 h-8 w-8" />
|
||||
Join us on Slack
|
||||
</a>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,6 +13,8 @@ const buttonVariants = cva(
|
|||
'bg-primary text-primary-foreground shadow-md hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
'hover-destructive':
|
||||
'shadow-none hover:bg-destructive/90 hover:text-destructive-foreground',
|
||||
outline:
|
||||
'border border-input hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
|
|
|
|||
|
|
@ -615,6 +615,51 @@ function IconUnlock({ className, ...props }: React.ComponentProps<'svg'>) {
|
|||
)
|
||||
}
|
||||
|
||||
function IconHome({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn('h-4 w-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function IconNetwork({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn('h-4 w-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<rect x="16" y="16" width="6" height="6" rx="1" />
|
||||
<rect x="2" y="16" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="2" width="6" height="6" rx="1" />
|
||||
<path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" />
|
||||
<path d="M12 12V8" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
IconEdit,
|
||||
IconNextChat,
|
||||
|
|
@ -647,5 +692,7 @@ export {
|
|||
IconNotice,
|
||||
IconSymbolFunction,
|
||||
IconLogout,
|
||||
IconUnlock
|
||||
IconUnlock,
|
||||
IconHome,
|
||||
IconNetwork
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = 'TableFooter'
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = 'TableCaption'
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { graphql } from '@/lib/gql/generates'
|
|||
import { Worker, WorkerKind } from '@/lib/gql/generates/graphql'
|
||||
import { useAuthenticatedGraphQLQuery } from '@/lib/tabby/gql'
|
||||
|
||||
import type { HealthInfo } from './use-health'
|
||||
import { useHealth, type HealthInfo } from './use-health'
|
||||
|
||||
const modelNameMap: Record<WorkerKind, 'chat_model' | 'model'> = {
|
||||
[WorkerKind.Chat]: 'chat_model',
|
||||
|
|
@ -43,7 +43,8 @@ export const getAllWorkersDocument = graphql(/* GraphQL */ `
|
|||
}
|
||||
`)
|
||||
|
||||
function useWorkers(healthInfo?: HealthInfo) {
|
||||
function useWorkers() {
|
||||
const { data: healthInfo } = useHealth()
|
||||
const { data } = useAuthenticatedGraphQLQuery(getAllWorkersDocument)
|
||||
let workers = data?.workers
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function useGraphQLForm<
|
|||
) {
|
||||
const { data } = useSession()
|
||||
const accessToken = data?.accessToken
|
||||
const onSubmit = async (variables: TVariables) => {
|
||||
const onSubmit = async (variables?: TVariables) => {
|
||||
let res
|
||||
try {
|
||||
res = await gqlClient.request({
|
||||
|
|
@ -43,6 +43,7 @@ export function useGraphQLForm<
|
|||
: undefined
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('err', err)
|
||||
const { errors = [] } = (err as any).response as GraphQLResponse
|
||||
for (const error of errors) {
|
||||
if (error.extensions && error.extensions['validation-errors']) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"graphql-request": "^6.1.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "^13.4.7",
|
||||
"next-themes": "^0.2.1",
|
||||
|
|
|
|||
|
|
@ -5250,6 +5250,11 @@ minimist@^1.2.0, minimist@^1.2.6:
|
|||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
moment@^2.29.4:
|
||||
version "2.29.4"
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
|
||||
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
|
||||
|
||||
mri@^1.1.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ impl DbConn {
|
|||
}
|
||||
|
||||
pub async fn create_invitation(&self, email: String) -> Result<i32> {
|
||||
if self.get_user_by_email(&email).await?.is_some() {
|
||||
return Err(anyhow!("User already registered"));
|
||||
}
|
||||
|
||||
let code = Uuid::new_v4().to_string();
|
||||
let res = self
|
||||
.conn
|
||||
|
|
@ -60,9 +64,6 @@ impl DbConn {
|
|||
Ok(rowid)
|
||||
})
|
||||
.await?;
|
||||
if res != 1 {
|
||||
return Err(anyhow!("failed to create invitation"));
|
||||
}
|
||||
|
||||
Ok(res as i32)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue