mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-20 16:25:24 +00:00
fix(web): Improve /repos page performance (#677)
* fix * wip * changelog
This commit is contained in:
parent
92d8abbbf2
commit
a52e97a54c
8 changed files with 575 additions and 131 deletions
|
|
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fixed issue where parenthesis in query params were not being encoded, resulting in a poor experience when embedding links in Markdown. [#674](https://github.com/sourcebot-dev/sourcebot/pull/674)
|
- Fixed issue where parenthesis in query params were not being encoded, resulting in a poor experience when embedding links in Markdown. [#674](https://github.com/sourcebot-dev/sourcebot/pull/674)
|
||||||
- Gitlab clone respects host protocol setting in environment variable. [#676](https://github.com/sourcebot-dev/sourcebot/pull/676)
|
- Gitlab clone respects host protocol setting in environment variable. [#676](https://github.com/sourcebot-dev/sourcebot/pull/676)
|
||||||
|
- Fixed performance issues with `/repos` page. [#677](https://github.com/sourcebot-dev/sourcebot/pull/677)
|
||||||
|
|
||||||
## [4.10.3] - 2025-12-12
|
## [4.10.3] - 2025-12-12
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,11 @@ export class RepoIndexManager {
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: RepoIndexingJobStatus.IN_PROGRESS,
|
status: RepoIndexingJobStatus.IN_PROGRESS,
|
||||||
|
repo: {
|
||||||
|
update: {
|
||||||
|
latestIndexingJobStatus: RepoIndexingJobStatus.IN_PROGRESS,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
type: true,
|
type: true,
|
||||||
|
|
@ -462,6 +467,11 @@ export class RepoIndexManager {
|
||||||
data: {
|
data: {
|
||||||
status: RepoIndexingJobStatus.COMPLETED,
|
status: RepoIndexingJobStatus.COMPLETED,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
|
repo: {
|
||||||
|
update: {
|
||||||
|
latestIndexingJobStatus: RepoIndexingJobStatus.COMPLETED,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
repo: true,
|
repo: true,
|
||||||
|
|
@ -522,6 +532,11 @@ export class RepoIndexManager {
|
||||||
status: RepoIndexingJobStatus.FAILED,
|
status: RepoIndexingJobStatus.FAILED,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
errorMessage: job.failedReason,
|
errorMessage: job.failedReason,
|
||||||
|
repo: {
|
||||||
|
update: {
|
||||||
|
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
select: { repo: true }
|
select: { repo: true }
|
||||||
});
|
});
|
||||||
|
|
@ -550,6 +565,11 @@ export class RepoIndexManager {
|
||||||
status: RepoIndexingJobStatus.FAILED,
|
status: RepoIndexingJobStatus.FAILED,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
errorMessage: 'Job stalled',
|
errorMessage: 'Job stalled',
|
||||||
|
repo: {
|
||||||
|
update: {
|
||||||
|
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
select: { repo: true, type: true }
|
select: { repo: true, type: true }
|
||||||
});
|
});
|
||||||
|
|
@ -572,6 +592,11 @@ export class RepoIndexManager {
|
||||||
status: RepoIndexingJobStatus.FAILED,
|
status: RepoIndexingJobStatus.FAILED,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
errorMessage: 'Job timed out',
|
errorMessage: 'Job timed out',
|
||||||
|
repo: {
|
||||||
|
update: {
|
||||||
|
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
select: { repo: true }
|
select: { repo: true }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Repo" ADD COLUMN "latestIndexingJobStatus" "RepoIndexingJobStatus";
|
||||||
|
|
@ -66,6 +66,7 @@ model Repo {
|
||||||
jobs RepoIndexingJob[]
|
jobs RepoIndexingJob[]
|
||||||
indexedAt DateTime? /// When the repo was last indexed successfully.
|
indexedAt DateTime? /// When the repo was last indexed successfully.
|
||||||
indexedCommitHash String? /// The commit hash of the last indexed commit (on HEAD).
|
indexedCommitHash String? /// The commit hash of the last indexed commit (on HEAD).
|
||||||
|
latestIndexingJobStatus RepoIndexingJobStatus? /// The status of the latest indexing job.
|
||||||
|
|
||||||
external_id String /// The id of the repo in the external service
|
external_id String /// The id of the repo in the external service
|
||||||
external_codeHostType CodeHostType /// The type of the external service (e.g., github, gitlab, etc.)
|
external_codeHostType CodeHostType /// The type of the external service (e.g., github, gitlab, etc.)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { Script } from "../scriptRunner";
|
import { Script } from "../scriptRunner";
|
||||||
import { PrismaClient } from "../../dist";
|
import { PrismaClient } from "../../dist";
|
||||||
|
|
||||||
const NUM_REPOS = 100000;
|
const NUM_REPOS = 1000;
|
||||||
|
const NUM_INDEXING_JOBS_PER_REPO = 10000;
|
||||||
|
const NUM_PERMISSION_JOBS_PER_REPO = 10000;
|
||||||
|
|
||||||
export const injectRepoData: Script = {
|
export const injectRepoData: Script = {
|
||||||
run: async (prisma: PrismaClient) => {
|
run: async (prisma: PrismaClient) => {
|
||||||
|
|
@ -32,30 +34,60 @@ export const injectRepoData: Script = {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
console.log(`Creating ${NUM_REPOS} repos...`);
|
console.log(`Creating ${NUM_REPOS} repos...`);
|
||||||
|
|
||||||
for (let i = 0; i < NUM_REPOS; i++) {
|
const statuses = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED'] as const;
|
||||||
await prisma.repo.create({
|
const indexingJobTypes = ['INDEX', 'CLEANUP'] as const;
|
||||||
data: {
|
|
||||||
name: `test-repo-${i}`,
|
for (let i = 0; i < NUM_REPOS; i++) {
|
||||||
isFork: false,
|
const repo = await prisma.repo.create({
|
||||||
isArchived: false,
|
data: {
|
||||||
metadata: {},
|
name: `test-repo-${i}`,
|
||||||
cloneUrl: `https://github.com/test-org/test-repo-${i}`,
|
isFork: false,
|
||||||
webUrl: `https://github.com/test-org/test-repo-${i}`,
|
isArchived: false,
|
||||||
orgId,
|
metadata: {},
|
||||||
external_id: `test-repo-${i}`,
|
cloneUrl: `https://github.com/test-org/test-repo-${i}`,
|
||||||
external_codeHostType: 'github',
|
webUrl: `https://github.com/test-org/test-repo-${i}`,
|
||||||
external_codeHostUrl: 'https://github.com',
|
orgId,
|
||||||
connections: {
|
external_id: `test-repo-${i}`,
|
||||||
create: {
|
external_codeHostType: 'github',
|
||||||
connectionId: connection.id,
|
external_codeHostUrl: 'https://github.com',
|
||||||
}
|
connections: {
|
||||||
|
create: {
|
||||||
|
connectionId: connection.id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let j = 0; j < NUM_PERMISSION_JOBS_PER_REPO; j++) {
|
||||||
|
const status = statuses[Math.floor(Math.random() * statuses.length)];
|
||||||
|
await prisma.repoPermissionSyncJob.create({
|
||||||
|
data: {
|
||||||
|
repoId: repo.id,
|
||||||
|
status,
|
||||||
|
completedAt: status === 'COMPLETED' || status === 'FAILED' ? new Date() : null,
|
||||||
|
errorMessage: status === 'FAILED' ? 'Mock error message' : null
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Created ${NUM_REPOS} repos.`);
|
for (let j = 0; j < NUM_INDEXING_JOBS_PER_REPO; j++) {
|
||||||
|
const status = statuses[Math.floor(Math.random() * statuses.length)];
|
||||||
|
const type = indexingJobTypes[Math.floor(Math.random() * indexingJobTypes.length)];
|
||||||
|
await prisma.repoIndexingJob.create({
|
||||||
|
data: {
|
||||||
|
repoId: repo.id,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
completedAt: status === 'COMPLETED' || status === 'FAILED' ? new Date() : null,
|
||||||
|
errorMessage: status === 'FAILED' ? 'Mock indexing error' : null,
|
||||||
|
metadata: {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Created ${NUM_REPOS} repos with associated jobs.`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -10,35 +10,31 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Input } from "@/components/ui/input"
|
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
|
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"
|
||||||
import { cn, getCodeHostCommitUrl, getCodeHostIcon, getCodeHostInfoForRepo, getRepoImageSrc } from "@/lib/utils"
|
import { cn, getCodeHostCommitUrl, getCodeHostIcon, getCodeHostInfoForRepo, getRepoImageSrc } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type ColumnFiltersState,
|
|
||||||
type SortingState,
|
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { cva } from "class-variance-authority"
|
import { cva } from "class-variance-authority"
|
||||||
import { ArrowUpDown, ExternalLink, MoreHorizontal, RefreshCwIcon } from "lucide-react"
|
import { ArrowDown, ArrowUp, ArrowUpDown, ExternalLink, Loader2, MoreHorizontal, RefreshCwIcon } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useMemo, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { getBrowsePath } from "../../browse/hooks/utils"
|
import { getBrowsePath } from "../../browse/hooks/utils"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter, useSearchParams, usePathname } from "next/navigation"
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { DisplayDate } from "../../components/DisplayDate"
|
import { DisplayDate } from "../../components/DisplayDate"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { NotificationDot } from "../../components/notificationDot"
|
import { NotificationDot } from "../../components/notificationDot"
|
||||||
import { CodeHostType } from "@sourcebot/db"
|
import { CodeHostType } from "@sourcebot/db"
|
||||||
|
import { useHotkeys } from "react-hotkeys-hook"
|
||||||
|
|
||||||
// @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS
|
// @see: https://v0.app/chat/repo-indexing-status-uhjdDim8OUS
|
||||||
|
|
||||||
|
|
@ -84,15 +80,29 @@ const getStatusBadge = (status: Repo["latestJobStatus"]) => {
|
||||||
return <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge>
|
return <Badge className={statusBadgeVariants({ status })}>{labels[status]}</Badge>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const columns: ColumnDef<Repo>[] = [
|
interface ColumnsContext {
|
||||||
|
onSortChange: (sortBy: string) => void;
|
||||||
|
currentSortBy?: string;
|
||||||
|
currentSortOrder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getColumns = (context: ColumnsContext): ColumnDef<Repo>[] => [
|
||||||
{
|
{
|
||||||
accessorKey: "displayName",
|
accessorKey: "displayName",
|
||||||
size: 400,
|
size: 400,
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const isActive = context.currentSortBy === 'displayName';
|
||||||
|
const Icon = isActive
|
||||||
|
? (context.currentSortOrder === 'asc' ? ArrowUp : ArrowDown)
|
||||||
|
: ArrowUpDown;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => context.onSortChange('displayName')}
|
||||||
|
>
|
||||||
Repository
|
Repository
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -159,14 +169,19 @@ export const columns: ColumnDef<Repo>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "indexedAt",
|
accessorKey: "indexedAt",
|
||||||
size: 200,
|
size: 200,
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const isActive = context.currentSortBy === 'indexedAt';
|
||||||
|
const Icon = isActive
|
||||||
|
? (context.currentSortOrder === 'asc' ? ArrowUp : ArrowDown)
|
||||||
|
: ArrowUpDown;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => context.onSortChange('indexedAt')}
|
||||||
>
|
>
|
||||||
Last synced
|
Last synced
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -271,46 +286,118 @@ export const columns: ColumnDef<Repo>[] = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const ReposTable = ({ data }: { data: Repo[] }) => {
|
interface ReposTableProps {
|
||||||
const [sorting, setSorting] = useState<SortingState>([])
|
data: Repo[];
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
initialSearch: string;
|
||||||
|
initialStatus: string;
|
||||||
|
initialSortBy?: string;
|
||||||
|
initialSortOrder: string;
|
||||||
|
stats: {
|
||||||
|
numCompleted: number
|
||||||
|
numFailed: number
|
||||||
|
numPending: number
|
||||||
|
numInProgress: number
|
||||||
|
numNoJobs: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReposTable = ({
|
||||||
|
data,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
totalCount,
|
||||||
|
initialSearch,
|
||||||
|
initialStatus,
|
||||||
|
initialSortBy,
|
||||||
|
initialSortOrder,
|
||||||
|
stats,
|
||||||
|
}: ReposTableProps) => {
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
const [rowSelection, setRowSelection] = useState({})
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
|
const [searchValue, setSearchValue] = useState(initialSearch)
|
||||||
|
const [isPendingSearch, setIsPendingSearch] = useState(false)
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const pathname = usePathname();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const {
|
// Focus search box when '/' is pressed
|
||||||
numCompleted,
|
useHotkeys('/', (event) => {
|
||||||
numInProgress,
|
event.preventDefault();
|
||||||
numPending,
|
searchInputRef.current?.focus();
|
||||||
numFailed,
|
});
|
||||||
numNoJobs,
|
|
||||||
} = useMemo(() => {
|
// Debounced search effect - only runs when searchValue changes
|
||||||
return {
|
useEffect(() => {
|
||||||
numCompleted: data.filter((repo) => repo.latestJobStatus === "COMPLETED").length,
|
setIsPendingSearch(true);
|
||||||
numInProgress: data.filter((repo) => repo.latestJobStatus === "IN_PROGRESS").length,
|
const timer = setTimeout(() => {
|
||||||
numPending: data.filter((repo) => repo.latestJobStatus === "PENDING").length,
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
numFailed: data.filter((repo) => repo.latestJobStatus === "FAILED").length,
|
if (searchValue) {
|
||||||
numNoJobs: data.filter((repo) => repo.latestJobStatus === null).length,
|
params.set('search', searchValue);
|
||||||
|
} else {
|
||||||
|
params.delete('search');
|
||||||
|
}
|
||||||
|
params.set('page', '1'); // Reset to page 1 on search
|
||||||
|
router.replace(`${pathname}?${params.toString()}`);
|
||||||
|
setIsPendingSearch(false);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
setIsPendingSearch(false);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchValue]);
|
||||||
|
|
||||||
|
const updateStatusFilter = (value: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value === 'all') {
|
||||||
|
params.delete('status');
|
||||||
|
} else {
|
||||||
|
params.set('status', value);
|
||||||
}
|
}
|
||||||
}, [data]);
|
params.set('page', '1'); // Reset to page 1 on filter change
|
||||||
|
router.replace(`${pathname}?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSortChange = (sortBy: string) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
// Toggle sort order if clicking the same column
|
||||||
|
if (initialSortBy === sortBy) {
|
||||||
|
const newOrder = initialSortOrder === 'asc' ? 'desc' : 'asc';
|
||||||
|
params.set('sortOrder', newOrder);
|
||||||
|
} else {
|
||||||
|
// Default to ascending when changing columns
|
||||||
|
params.set('sortBy', sortBy);
|
||||||
|
params.set('sortOrder', 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
params.set('page', '1'); // Reset to page 1 on sort change
|
||||||
|
router.replace(`${pathname}?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / pageSize);
|
||||||
|
|
||||||
|
const columns = getColumns({
|
||||||
|
onSortChange: handleSortChange,
|
||||||
|
currentSortBy: initialSortBy,
|
||||||
|
currentSortOrder: initialSortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
onSortingChange: setSorting,
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
columnResizeMode: 'onChange',
|
columnResizeMode: 'onChange',
|
||||||
enableColumnResizing: false,
|
enableColumnResizing: false,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
},
|
},
|
||||||
|
|
@ -319,28 +406,34 @@ export const ReposTable = ({ data }: { data: Repo[] }) => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex items-center gap-4 py-4">
|
<div className="flex items-center gap-4 py-4">
|
||||||
<Input
|
<InputGroup className="max-w-sm">
|
||||||
placeholder="Filter repositories..."
|
<InputGroupInput
|
||||||
value={(table.getColumn("displayName")?.getFilterValue() as string) ?? ""}
|
ref={searchInputRef}
|
||||||
onChange={(event) => table.getColumn("displayName")?.setFilterValue(event.target.value)}
|
placeholder="Filter repositories..."
|
||||||
className="max-w-sm"
|
value={searchValue}
|
||||||
/>
|
onChange={(event) => setSearchValue(event.target.value)}
|
||||||
|
className="ring-0"
|
||||||
|
/>
|
||||||
|
{isPendingSearch && (
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
)}
|
||||||
|
</InputGroup>
|
||||||
<Select
|
<Select
|
||||||
value={(table.getColumn("latestJobStatus")?.getFilterValue() as string) ?? "all"}
|
value={initialStatus}
|
||||||
onValueChange={(value) => {
|
onValueChange={updateStatusFilter}
|
||||||
table.getColumn("latestJobStatus")?.setFilterValue(value === "all" ? "" : value)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[180px]">
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectValue placeholder="Filter by status" />
|
<SelectValue placeholder="Filter by status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Filter by status</SelectItem>
|
<SelectItem value="all">Filter by status</SelectItem>
|
||||||
<SelectItem value="COMPLETED">Completed ({numCompleted})</SelectItem>
|
<SelectItem value="COMPLETED">Completed ({stats.numCompleted})</SelectItem>
|
||||||
<SelectItem value="IN_PROGRESS">In progress ({numInProgress})</SelectItem>
|
<SelectItem value="IN_PROGRESS">In progress ({stats.numInProgress})</SelectItem>
|
||||||
<SelectItem value="PENDING">Pending ({numPending})</SelectItem>
|
<SelectItem value="PENDING">Pending ({stats.numPending})</SelectItem>
|
||||||
<SelectItem value="FAILED">Failed ({numFailed})</SelectItem>
|
<SelectItem value="FAILED">Failed ({stats.numFailed})</SelectItem>
|
||||||
<SelectItem value="null">No status ({numNoJobs})</SelectItem>
|
<SelectItem value="none">No status ({stats.numNoJobs})</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -401,18 +494,32 @@ export const ReposTable = ({ data }: { data: Repo[] }) => {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end space-x-2 py-4">
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
<div className="flex-1 text-sm text-muted-foreground">
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
{table.getFilteredRowModel().rows.length} {data.length > 1 ? 'repositories' : 'repository'} total
|
{totalCount} {totalCount !== 1 ? 'repositories' : 'repository'} total
|
||||||
|
{totalPages > 1 && ` • Page ${currentPage} of ${totalPages}`}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-x-2">
|
<div className="space-x-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => table.previousPage()}
|
onClick={() => {
|
||||||
disabled={!table.getCanPreviousPage()}
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set('page', String(currentPage - 1));
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set('page', String(currentPage + 1));
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,49 @@ import { ServiceErrorException } from "@/lib/serviceError";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { withOptionalAuthV2 } from "@/withAuthV2";
|
import { withOptionalAuthV2 } from "@/withAuthV2";
|
||||||
import { ReposTable } from "./components/reposTable";
|
import { ReposTable } from "./components/reposTable";
|
||||||
import { RepoIndexingJobStatus } from "@sourcebot/db";
|
import { RepoIndexingJobStatus, Prisma } from "@sourcebot/db";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
export default async function ReposPage() {
|
interface ReposPageProps {
|
||||||
|
searchParams: Promise<{
|
||||||
|
page?: string;
|
||||||
|
pageSize?: string;
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
const _repos = await getReposWithLatestJob();
|
export default async function ReposPage({ searchParams }: ReposPageProps) {
|
||||||
if (isServiceError(_repos)) {
|
const params = await searchParams;
|
||||||
throw new ServiceErrorException(_repos);
|
|
||||||
|
// Parse pagination parameters with defaults
|
||||||
|
const page = z.number().int().positive().safeParse(params.page).data ?? 1;
|
||||||
|
const pageSize = z.number().int().positive().safeParse(params.pageSize).data ?? 5;
|
||||||
|
|
||||||
|
// Parse filter parameters
|
||||||
|
const search = z.string().optional().safeParse(params.search).data ?? '';
|
||||||
|
const status = z.enum(['all', 'none', 'COMPLETED', 'IN_PROGRESS', 'PENDING', 'FAILED']).safeParse(params.status).data ?? 'all';
|
||||||
|
const sortBy = z.enum(['displayName', 'indexedAt']).safeParse(params.sortBy).data ?? undefined;
|
||||||
|
const sortOrder = z.enum(['asc', 'desc']).safeParse(params.sortOrder).data ?? 'asc';
|
||||||
|
|
||||||
|
// Calculate skip for pagination
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const _result = await getRepos({
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
if (isServiceError(_result)) {
|
||||||
|
throw new ServiceErrorException(_result);
|
||||||
}
|
}
|
||||||
|
|
||||||
const repos = _repos
|
const { repos, totalCount, stats } = _result;
|
||||||
.map((repo) => ({
|
|
||||||
...repo,
|
|
||||||
latestJobStatus: repo.jobs.length > 0 ? repo.jobs[0].status : null,
|
|
||||||
isFirstTimeIndex: repo.indexedAt === null && repo.jobs.filter((job) => job.status === RepoIndexingJobStatus.PENDING || job.status === RepoIndexingJobStatus.IN_PROGRESS).length > 0,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (a.isFirstTimeIndex && !b.isFirstTimeIndex) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (!a.isFirstTimeIndex && b.isFirstTimeIndex) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -34,42 +53,129 @@ export default async function ReposPage() {
|
||||||
<h1 className="text-3xl font-semibold">Repositories</h1>
|
<h1 className="text-3xl font-semibold">Repositories</h1>
|
||||||
<p className="text-muted-foreground mt-2">View and manage your code repositories and their indexing status.</p>
|
<p className="text-muted-foreground mt-2">View and manage your code repositories and their indexing status.</p>
|
||||||
</div>
|
</div>
|
||||||
<ReposTable data={repos.map((repo) => ({
|
<ReposTable
|
||||||
id: repo.id,
|
data={repos.map((repo) => ({
|
||||||
name: repo.name,
|
id: repo.id,
|
||||||
displayName: repo.displayName ?? repo.name,
|
name: repo.name,
|
||||||
isArchived: repo.isArchived,
|
displayName: repo.displayName ?? repo.name,
|
||||||
isPublic: repo.isPublic,
|
isArchived: repo.isArchived,
|
||||||
indexedAt: repo.indexedAt,
|
isPublic: repo.isPublic,
|
||||||
createdAt: repo.createdAt,
|
indexedAt: repo.indexedAt,
|
||||||
webUrl: repo.webUrl,
|
createdAt: repo.createdAt,
|
||||||
imageUrl: repo.imageUrl,
|
webUrl: repo.webUrl,
|
||||||
latestJobStatus: repo.latestJobStatus,
|
imageUrl: repo.imageUrl,
|
||||||
isFirstTimeIndex: repo.isFirstTimeIndex,
|
latestJobStatus: repo.latestIndexingJobStatus,
|
||||||
codeHostType: repo.external_codeHostType,
|
isFirstTimeIndex: repo.indexedAt === null,
|
||||||
indexedCommitHash: repo.indexedCommitHash,
|
codeHostType: repo.external_codeHostType,
|
||||||
}))} />
|
indexedCommitHash: repo.indexedCommitHash,
|
||||||
|
}))}
|
||||||
|
currentPage={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
totalCount={totalCount}
|
||||||
|
initialSearch={search}
|
||||||
|
initialStatus={status}
|
||||||
|
initialSortBy={sortBy}
|
||||||
|
initialSortOrder={sortOrder}
|
||||||
|
stats={stats}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReposWithLatestJob = async () => sew(() =>
|
interface GetReposParams {
|
||||||
withOptionalAuthV2(async ({ prisma, org }) => {
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
search: string;
|
||||||
|
status: 'all' | 'none' | 'COMPLETED' | 'IN_PROGRESS' | 'PENDING' | 'FAILED';
|
||||||
|
sortBy?: 'displayName' | 'indexedAt';
|
||||||
|
sortOrder: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRepos = async ({ skip, take, search, status, sortBy, sortOrder }: GetReposParams) => sew(() =>
|
||||||
|
withOptionalAuthV2(async ({ prisma }) => {
|
||||||
|
const whereClause: Prisma.RepoWhereInput = {
|
||||||
|
...(search ? {
|
||||||
|
displayName: { contains: search, mode: 'insensitive' },
|
||||||
|
} : {}),
|
||||||
|
latestIndexingJobStatus:
|
||||||
|
status === 'all' ? undefined :
|
||||||
|
status === 'none' ? null :
|
||||||
|
status
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build orderBy clause based on sortBy and sortOrder
|
||||||
|
const orderByClause: Prisma.RepoOrderByWithRelationInput = {};
|
||||||
|
|
||||||
|
if (sortBy === 'displayName') {
|
||||||
|
orderByClause.displayName = sortOrder === 'asc' ? 'asc' : 'desc';
|
||||||
|
} else if (sortBy === 'indexedAt') {
|
||||||
|
orderByClause.indexedAt = sortOrder === 'asc' ? 'asc' : 'desc';
|
||||||
|
} else {
|
||||||
|
// Default to displayName asc
|
||||||
|
orderByClause.displayName = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
const repos = await prisma.repo.findMany({
|
const repos = await prisma.repo.findMany({
|
||||||
include: {
|
skip,
|
||||||
jobs: {
|
take,
|
||||||
orderBy: {
|
where: whereClause,
|
||||||
createdAt: 'desc'
|
orderBy: orderByClause,
|
||||||
},
|
|
||||||
take: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
name: 'asc'
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
orgId: org.id,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return repos;
|
|
||||||
|
// Calculate total count using the filtered where clause
|
||||||
|
const totalCount = await prisma.repo.count({
|
||||||
|
where: whereClause
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status stats
|
||||||
|
const [
|
||||||
|
numCompleted,
|
||||||
|
numFailed,
|
||||||
|
numPending,
|
||||||
|
numInProgress,
|
||||||
|
numNoJobs
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.repo.count({
|
||||||
|
where: {
|
||||||
|
...whereClause,
|
||||||
|
latestIndexingJobStatus: RepoIndexingJobStatus.COMPLETED,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.repo.count({
|
||||||
|
where: {
|
||||||
|
...whereClause,
|
||||||
|
latestIndexingJobStatus: RepoIndexingJobStatus.FAILED,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.repo.count({
|
||||||
|
where: {
|
||||||
|
...whereClause,
|
||||||
|
latestIndexingJobStatus: RepoIndexingJobStatus.PENDING,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.repo.count({
|
||||||
|
where: {
|
||||||
|
...whereClause,
|
||||||
|
latestIndexingJobStatus: RepoIndexingJobStatus.IN_PROGRESS,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.repo.count({
|
||||||
|
where: {
|
||||||
|
...whereClause,
|
||||||
|
latestIndexingJobStatus: null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
repos,
|
||||||
|
totalCount,
|
||||||
|
stats: {
|
||||||
|
numCompleted,
|
||||||
|
numFailed,
|
||||||
|
numPending,
|
||||||
|
numInProgress,
|
||||||
|
numNoJobs,
|
||||||
|
}
|
||||||
|
};
|
||||||
}));
|
}));
|
||||||
170
packages/web/src/components/ui/input-group.tsx
Normal file
170
packages/web/src/components/ui/input-group.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group border-input bg-background relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||||
|
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||||
|
|
||||||
|
// Variants based on alignment.
|
||||||
|
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||||
|
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||||
|
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||||
|
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||||
|
|
||||||
|
// Focus state.
|
||||||
|
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||||
|
|
||||||
|
// Error state.
|
||||||
|
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||||
|
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"text-sm shadow-none flex gap-2 items-center",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||||
|
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 h-10 px-3 py-2 text-base md:text-sm rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-transparent placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue