perf(web): разбиение бандла и runtime-оптимизации
- autoCodeSplitting роутов: начальный JS 1012КБ/298гз → 426КБ/131гз (−58%), админ-чанки грузятся по требованию - фильтры грида: убрана искусственная задержка 200мс - SystemMonitorPopover: поллинг 4 запросов/30с только при открытом поповере - /me в beforeLoad через ensureQueryData (кэш вместо запроса на каждую навигацию) - глобальный staleTime 30с (мутации инвалидируют кэш явно) - режим analyze + rollup-plugin-visualizer (pnpm --filter web analyze)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, type ReactNode } from 'react'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Activity, AppWindow, KeyRound, Users } from 'lucide-react'
|
import { Activity, AppWindow, KeyRound, Users } from 'lucide-react'
|
||||||
|
|
||||||
@@ -76,6 +76,9 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
|||||||
|
|
||||||
/** Live system monitor popover — app-shell-7, Auth Portal metrics. @see https://reui.io/preview/base/app-shell-7 */
|
/** Live system monitor popover — app-shell-7, Auth Portal metrics. @see https://reui.io/preview/base/app-shell-7 */
|
||||||
export function SystemMonitorPopover() {
|
export function SystemMonitorPopover() {
|
||||||
|
// Поллинг живёт только пока поповер открыт: закрытый монитор не должен
|
||||||
|
// каждые 30 секунд тянуть /ready, каталог и админские списки.
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
const meQ = useQuery(meQueryOptions)
|
const meQ = useQuery(meQueryOptions)
|
||||||
const isAdmin = Boolean(meQ.data?.is_admin)
|
const isAdmin = Boolean(meQ.data?.is_admin)
|
||||||
|
|
||||||
@@ -86,24 +89,33 @@ export function SystemMonitorPopover() {
|
|||||||
if (!res.ok) throw new Error('not ready')
|
if (!res.ok) throw new Error('not ready')
|
||||||
return (await res.json()) as { ok?: boolean }
|
return (await res.json()) as { ok?: boolean }
|
||||||
},
|
},
|
||||||
|
enabled: open,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
const catalogQ = useQuery({ ...catalogQueryOptions, refetchInterval: 30_000 })
|
const catalogQ = useQuery({
|
||||||
|
...catalogQueryOptions,
|
||||||
|
enabled: open,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
const usersQ = useQuery({
|
const usersQ = useQuery({
|
||||||
...usersQueryOptions,
|
...usersQueryOptions,
|
||||||
enabled: isAdmin,
|
enabled: isAdmin && open,
|
||||||
retry: false,
|
retry: false,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
})
|
})
|
||||||
const oidcQ = useQuery({
|
const oidcQ = useQuery({
|
||||||
...oidcClientsQueryOptions,
|
...oidcClientsQueryOptions,
|
||||||
enabled: isAdmin,
|
enabled: isAdmin && open,
|
||||||
retry: false,
|
retry: false,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const apiOk = readyQ.data?.ok === true || readyQ.isSuccess
|
const monitorFetched = readyQ.isFetched || catalogQ.isFetched
|
||||||
|
// До первого открытия данных нет — нейтральное состояние вместо ложной тревоги.
|
||||||
|
const apiOk = readyQ.isFetched
|
||||||
|
? readyQ.data?.ok === true || readyQ.isSuccess
|
||||||
|
: true
|
||||||
const appsCount = catalogQ.data?.apps.length ?? 0
|
const appsCount = catalogQ.data?.apps.length ?? 0
|
||||||
const usersCount = isAdmin ? (usersQ.data?.length ?? 0) : null
|
const usersCount = isAdmin ? (usersQ.data?.length ?? 0) : null
|
||||||
const oidcCount = isAdmin ? (oidcQ.data?.length ?? 0) : null
|
const oidcCount = isAdmin ? (oidcQ.data?.length ?? 0) : null
|
||||||
@@ -163,10 +175,10 @@ export function SystemMonitorPopover() {
|
|||||||
[apiOk, appsCount, disabledUsers, oidcCount, usersCount],
|
[apiOk, appsCount, disabledUsers, oidcCount, usersCount],
|
||||||
)
|
)
|
||||||
|
|
||||||
const spiking = metrics.some((m) => m.alert) || !apiOk
|
const spiking = monitorFetched && (metrics.some((m) => m.alert) || !apiOk)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -771,7 +771,6 @@ export function AdminUsersGrid({
|
|||||||
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
const [filters, setFilters] = useState<Filter[]>(createDefaultFilters)
|
||||||
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
|
const [bulkRole, setBulkRole] = useState<BulkRole>('user')
|
||||||
const [filteredData, setFilteredData] = useState<AdminUser[]>(users)
|
const [filteredData, setFilteredData] = useState<AdminUser[]>(users)
|
||||||
const [filterLoading, setFilterLoading] = useState(false)
|
|
||||||
const isInitialLoad = useRef(true)
|
const isInitialLoad = useRef(true)
|
||||||
const lastAppliedActiveKey = useRef<string>(
|
const lastAppliedActiveKey = useRef<string>(
|
||||||
serializeActiveFiltersKey(getActiveFilters(createDefaultFilters())),
|
serializeActiveFiltersKey(getActiveFilters(createDefaultFilters())),
|
||||||
@@ -851,16 +850,6 @@ export function AdminUsersGrid({
|
|||||||
[users],
|
[users],
|
||||||
)
|
)
|
||||||
|
|
||||||
const simulateAsyncFiltering = useCallback(
|
|
||||||
async (newFilters: Filter[]) => {
|
|
||||||
setFilterLoading(true)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
||||||
setFilteredData(applyFilters(newFilters))
|
|
||||||
setFilterLoading(false)
|
|
||||||
},
|
|
||||||
[applyFilters],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleFiltersChange = useCallback(
|
const handleFiltersChange = useCallback(
|
||||||
(newFilters: Filter[]) => {
|
(newFilters: Filter[]) => {
|
||||||
setFilters(newFilters)
|
setFilters(newFilters)
|
||||||
@@ -870,9 +859,8 @@ export function AdminUsersGrid({
|
|||||||
lastAppliedActiveKey.current = nextKey
|
lastAppliedActiveKey.current = nextKey
|
||||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
|
||||||
setRowSelection({})
|
setRowSelection({})
|
||||||
void simulateAsyncFiltering(newFilters)
|
|
||||||
},
|
},
|
||||||
[simulateAsyncFiltering],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -933,7 +921,7 @@ export function AdminUsersGrid({
|
|||||||
.getSelectedRowModel()
|
.getSelectedRowModel()
|
||||||
.rows.map((row) => row.original.id)
|
.rows.map((row) => row.original.id)
|
||||||
const showClearButton = filters.length > 0
|
const showClearButton = filters.length > 0
|
||||||
const gridLoading = isLoading || filterLoading
|
const gridLoading = isLoading
|
||||||
|
|
||||||
const handleClearSelection = useCallback(() => setRowSelection({}), [])
|
const handleClearSelection = useCallback(() => setRowSelection({}), [])
|
||||||
|
|
||||||
@@ -956,8 +944,7 @@ export function AdminUsersGrid({
|
|||||||
)
|
)
|
||||||
setFilters(next)
|
setFilters(next)
|
||||||
setRowSelection({})
|
setRowSelection({})
|
||||||
void simulateAsyncFiltering(next)
|
}, [])
|
||||||
}, [simulateAsyncFiltering])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delay={200}>
|
<TooltipProvider delay={200}>
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import '@authportal/ui/globals.css'
|
|||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: { retry: 1, refetchOnWindowFocus: false },
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
// Данные портала меняются редко; мутации инвалидируют кэш явно.
|
||||||
|
// Без staleTime каждый маунт компонента рефетчил /me, каталог и т.п.
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,17 +6,18 @@ import {
|
|||||||
jwtClaimsStaleVsMe,
|
jwtClaimsStaleVsMe,
|
||||||
reissueAccessToken,
|
reissueAccessToken,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
import { api } from '@/lib/api-client'
|
import { meQueryKey, meQueryOptions } from '@/queries/auth'
|
||||||
import type { MeResponse } from '@authportal/shared'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
beforeLoad: async ({ location }) => {
|
beforeLoad: async ({ context: { queryClient }, location }) => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw redirect({ to: '/' })
|
throw redirect({ to: '/' })
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const me = await api.get<MeResponse>('/api/v1/auth/me')
|
// Кэшированный /me вместо сырого запроса на каждый вход в auth-дерево;
|
||||||
|
// сайдбар, NavUser и монитор читают тот же кэш (staleTime из QueryClient).
|
||||||
|
const me = await queryClient.ensureQueryData(meQueryOptions)
|
||||||
if (jwtClaimsStaleVsMe(token, me)) {
|
if (jwtClaimsStaleVsMe(token, me)) {
|
||||||
await reissueAccessToken()
|
await reissueAccessToken()
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ export const Route = createFileRoute('/_auth')({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err && typeof err === 'object' && 'to' in err) throw err
|
if (err && typeof err === 'object' && 'to' in err) throw err
|
||||||
|
queryClient.removeQueries({ queryKey: meQueryKey })
|
||||||
clearToken()
|
clearToken()
|
||||||
throw redirect({ to: '/' })
|
throw redirect({ to: '/' })
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -3,12 +3,20 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
|
import { TanStackRouterVite } from '@tanstack/router-vite-plugin'
|
||||||
|
import { visualizer } from 'rollup-plugin-visualizer'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => ({
|
||||||
plugins: [
|
plugins: [
|
||||||
TanStackRouterVite({ routesDirectory: './src/routes' }),
|
TanStackRouterVite({
|
||||||
|
routesDirectory: './src/routes',
|
||||||
|
autoCodeSplitting: true,
|
||||||
|
}),
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
// pnpm --filter web analyze → dist/stats.html
|
||||||
|
...(mode === 'analyze'
|
||||||
|
? [visualizer({ filename: 'dist/stats.html', gzipSize: true })]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -32,4 +40,4 @@ export default defineConfig({
|
|||||||
'/oauth': 'http://localhost:8080',
|
'/oauth': 'http://localhost:8080',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user