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 { usePathname } from 'next/navigation'
|
||||||
import { cva } from 'class-variance-authority'
|
import { cva } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { useSession } from '@/lib/tabby/auth'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { IconHome, IconNetwork, IconUsers } from '@/components/ui/icons'
|
||||||
import UserPanel from '@/components/user-panel'
|
import UserPanel from '@/components/user-panel'
|
||||||
|
|
||||||
export interface SidebarProps {
|
export interface SidebarProps {
|
||||||
|
|
@ -13,6 +15,8 @@ export interface SidebarProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({ children, className }: SidebarProps) {
|
export default function Sidebar({ children, className }: SidebarProps) {
|
||||||
|
const { data: session } = useSession()
|
||||||
|
const isAdmin = session?.isAdmin || false
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('grid overflow-hidden lg:grid-cols-[280px_1fr]', className)}
|
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">
|
<div className="flex-1">
|
||||||
<nav className="grid items-start gap-4 px-4 text-sm font-medium">
|
<nav className="grid items-start gap-4 px-4 text-sm font-medium">
|
||||||
<SidebarButton href="/">
|
<SidebarButton href="/">
|
||||||
<svg
|
<IconHome /> Home
|
||||||
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
|
|
||||||
</SidebarButton>
|
</SidebarButton>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<SidebarButton href="/cluster">
|
||||||
|
<IconNetwork /> Cluster Information
|
||||||
|
</SidebarButton>
|
||||||
|
<SidebarButton href="/team">
|
||||||
|
<IconUsers /> Team Management
|
||||||
|
</SidebarButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ import { Header } from '@/components/header'
|
||||||
import Sidebar from './components/sidebar'
|
import Sidebar from './components/sidebar'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Dashboard'
|
title: {
|
||||||
|
default: 'Home',
|
||||||
|
template: `Tabby - %s`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
|
|
|
||||||
|
|
@ -1,100 +1,38 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import { graphql } from '@/lib/gql/generates'
|
import { graphql } from '@/lib/gql/generates'
|
||||||
import { WorkerKind } from '@/lib/gql/generates/graphql'
|
|
||||||
import { useHealth } from '@/lib/hooks/use-health'
|
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 { 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 { Separator } from '@/components/ui/separator'
|
||||||
import { CopyButton } from '@/components/copy-button'
|
import { CopyButton } from '@/components/copy-button'
|
||||||
|
import SlackDialog from '@/components/slack-dialog'
|
||||||
import WorkerCard from './components/worker-card'
|
|
||||||
|
|
||||||
const COMMUNITY_DIALOG_SHOWN_KEY = 'community-dialog-shown'
|
|
||||||
|
|
||||||
export default function Home() {
|
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 (
|
return (
|
||||||
<div className="p-4 lg:p-16">
|
<div className="p-4 lg:p-16">
|
||||||
<MainPanel />
|
<MainPanel />
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<SlackDialog />
|
||||||
<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>
|
|
||||||
</div>
|
</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) {
|
function toBadgeString(str: string) {
|
||||||
return encodeURIComponent(str.replaceAll('-', '--'))
|
return encodeURIComponent(str.replaceAll('-', '--'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRegistrationTokenDocument = graphql(/* GraphQL */ `
|
const meQuery = graphql(/* GraphQL */ `
|
||||||
query GetRegistrationToken {
|
query MeQuery {
|
||||||
registrationToken
|
me {
|
||||||
|
authToken
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
|
|
||||||
function MainPanel() {
|
function MainPanel() {
|
||||||
const { data: healthInfo } = useHealth()
|
const { data: healthInfo } = useHealth()
|
||||||
const workers = useWorkers(healthInfo)
|
const { data } = useAuthenticatedGraphQLQuery(meQuery)
|
||||||
const { data: registrationTokenRes } = useAuthenticatedGraphQLQuery(
|
|
||||||
getRegistrationTokenDocument
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!healthInfo) return
|
if (!healthInfo || !data) return
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-3">
|
<div className="flex w-full flex-col gap-3">
|
||||||
|
|
@ -115,47 +53,24 @@ function MainPanel() {
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
<div className="flex items-center">
|
||||||
<div className="mt-4 rounded-lg bg-zinc-100 p-4 dark:bg-zinc-800">
|
<span className="mr-2">Token:</span>
|
||||||
<span className="font-bold">Workers</span>
|
<code className="rounded-lg text-sm text-red-600">
|
||||||
|
{data.me.authToken}
|
||||||
{!!registrationTokenRes?.registrationToken && (
|
</code>
|
||||||
<div className="flex items-center gap-1">
|
<CopyButton value={data.me.authToken} />
|
||||||
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>
|
</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>
|
</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 { Metadata } from 'next'
|
||||||
|
|
||||||
|
import { Header } from '@/components/header'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'API'
|
title: 'API'
|
||||||
}
|
}
|
||||||
|
|
@ -7,5 +9,10 @@ export const metadata: Metadata = {
|
||||||
const serverUrl = process.env.NEXT_PUBLIC_TABBY_SERVER_URL || ''
|
const serverUrl = process.env.NEXT_PUBLIC_TABBY_SERVER_URL || ''
|
||||||
|
|
||||||
export default function IndexPage() {
|
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()
|
useAuthenticatedSession()
|
||||||
|
|
||||||
const { data } = useHealth()
|
const { data } = useHealth()
|
||||||
const workers = useWorkers(data)
|
const workers = useWorkers()
|
||||||
const isChatEnabled = has(workers, WorkerKind.Chat)
|
const isChatEnabled = has(workers, WorkerKind.Chat)
|
||||||
const version = data?.version?.git_describe
|
const version = data?.version?.git_describe
|
||||||
const { data: latestRelease } = useLatestRelease()
|
const { data: latestRelease } = useLatestRelease()
|
||||||
|
|
@ -34,6 +34,9 @@ export function Header() {
|
||||||
<Link href="/" className={cn(buttonVariants({ variant: 'link' }))}>
|
<Link href="/" className={cn(buttonVariants({ variant: 'link' }))}>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/api" className={cn(buttonVariants({ variant: 'link' }))}>
|
||||||
|
API
|
||||||
|
</Link>
|
||||||
{isChatEnabled && (
|
{isChatEnabled && (
|
||||||
<Link
|
<Link
|
||||||
href="/playground"
|
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',
|
'bg-primary text-primary-foreground shadow-md hover:bg-primary/90',
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
'hover-destructive':
|
||||||
|
'shadow-none hover:bg-destructive/90 hover:text-destructive-foreground',
|
||||||
outline:
|
outline:
|
||||||
'border border-input hover:bg-accent hover:text-accent-foreground',
|
'border border-input hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary:
|
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 {
|
export {
|
||||||
IconEdit,
|
IconEdit,
|
||||||
IconNextChat,
|
IconNextChat,
|
||||||
|
|
@ -647,5 +692,7 @@ export {
|
||||||
IconNotice,
|
IconNotice,
|
||||||
IconSymbolFunction,
|
IconSymbolFunction,
|
||||||
IconLogout,
|
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 { Worker, WorkerKind } from '@/lib/gql/generates/graphql'
|
||||||
import { useAuthenticatedGraphQLQuery } from '@/lib/tabby/gql'
|
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'> = {
|
const modelNameMap: Record<WorkerKind, 'chat_model' | 'model'> = {
|
||||||
[WorkerKind.Chat]: 'chat_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)
|
const { data } = useAuthenticatedGraphQLQuery(getAllWorkersDocument)
|
||||||
let workers = data?.workers
|
let workers = data?.workers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export function useGraphQLForm<
|
||||||
) {
|
) {
|
||||||
const { data } = useSession()
|
const { data } = useSession()
|
||||||
const accessToken = data?.accessToken
|
const accessToken = data?.accessToken
|
||||||
const onSubmit = async (variables: TVariables) => {
|
const onSubmit = async (variables?: TVariables) => {
|
||||||
let res
|
let res
|
||||||
try {
|
try {
|
||||||
res = await gqlClient.request({
|
res = await gqlClient.request({
|
||||||
|
|
@ -43,6 +43,7 @@ export function useGraphQLForm<
|
||||||
: undefined
|
: undefined
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('err', err)
|
||||||
const { errors = [] } = (err as any).response as GraphQLResponse
|
const { errors = [] } = (err as any).response as GraphQLResponse
|
||||||
for (const error of errors) {
|
for (const error of errors) {
|
||||||
if (error.extensions && error.extensions['validation-errors']) {
|
if (error.extensions && error.extensions['validation-errors']) {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@
|
||||||
"graphql-request": "^6.1.0",
|
"graphql-request": "^6.1.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
"moment": "^2.29.4",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "^13.4.7",
|
"next": "^13.4.7",
|
||||||
"next-themes": "^0.2.1",
|
"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"
|
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
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:
|
mri@^1.1.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
|
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> {
|
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 code = Uuid::new_v4().to_string();
|
||||||
let res = self
|
let res = self
|
||||||
.conn
|
.conn
|
||||||
|
|
@ -60,9 +64,6 @@ impl DbConn {
|
||||||
Ok(rowid)
|
Ok(rowid)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
if res != 1 {
|
|
||||||
return Err(anyhow!("failed to create invitation"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(res as i32)
|
Ok(res as i32)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue