fix(web): улучшить адаптивность сетки графиков и дашборда
Docker / build (push) Failing after 22s

Добавлены классы для двухколоночного макета в компоненты ChartsGrid и OpsDashboard, улучшая отображение на больших экранах. Теперь элементы будут более эффективно использовать доступное пространство.
This commit is contained in:
Denozordec
2026-07-18 13:26:16 +07:00
parent f8e13a4a72
commit 84cc800d54
16 changed files with 827 additions and 15 deletions
+16 -7
View File
@@ -51,6 +51,10 @@ import { AppsMenu } from '@/components/layout/apps-menu'
import { AppSwitcher } from '@/components/app-switcher'
import { GlobalSearch, useGlobalSearchHotkey } from '@/components/global-search'
import { dashboardStatsQueryOptions } from '@/queries/dashboard'
import {
can,
permissionForPath,
} from '@/lib/auth'
interface NavItem {
to: string
@@ -140,13 +144,18 @@ export function AppShell({ children }: { children: ReactNode }) {
const navGroups: NavGroup[] = NAV_GROUPS.map((group) => ({
...group,
items: group.items.map((item) => {
if (item.to === '/dashboard' && stats?.issuesCount) {
return { ...item, badge: stats.issuesCount }
}
return item
}),
}))
items: group.items
.filter((item) => {
const perm = permissionForPath(item.to)
return !perm || can(perm)
})
.map((item) => {
if (item.to === '/dashboard' && stats?.issuesCount) {
return { ...item, badge: stats.issuesCount }
}
return item
}),
})).filter((g) => g.items.length > 0)
return (
<TooltipProvider delay={0}>
+29 -3
View File
@@ -8,6 +8,7 @@ import type {
Payment,
BalanceLedgerRow,
} from '@/types/entities'
import { clearToken, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
@@ -26,11 +27,21 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
if (options.body != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (isAuthEnabled()) {
const token = getToken()
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
}
const res = await fetch(url, {
...options,
headers,
})
if (!res.ok) {
if (isAuthEnabled() && res.status === 401) {
clearToken()
redirectToPortalLogin()
}
let message = res.statusText || 'API error'
try {
const data = (await res.json()) as {
@@ -141,13 +152,23 @@ export const api = {
},
downloadBackupJson: async (): Promise<Blob> => {
const res = await fetch(`${API_BASE}/api/backup/json`)
const headers = new Headers()
if (isAuthEnabled()) {
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
}
const res = await fetch(`${API_BASE}/api/backup/json`, { headers })
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
return res.blob()
},
downloadBackupDatabase: async (): Promise<Blob> => {
const res = await fetch(`${API_BASE}/api/backup/database`)
const headers = new Headers()
if (isAuthEnabled()) {
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
}
const res = await fetch(`${API_BASE}/api/backup/database`, { headers })
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
return res.blob()
},
@@ -156,9 +177,14 @@ export const api = {
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
importBackupDatabase: async (buffer: ArrayBuffer) => {
const headers = new Headers({ 'Content-Type': 'application/octet-stream' })
if (isAuthEnabled()) {
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
}
const res = await fetch(`${API_BASE}/api/backup/database`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
headers,
body: buffer,
})
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status)
+165
View File
@@ -0,0 +1,165 @@
/** Portal JWT storage + claims helpers for VPS Tracker UI. */
const TOKEN_KEY = 'vps_auth_token'
export type AccessClaims = {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss?: string
exp?: number
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function isAuthEnabled(): boolean {
return (
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
import.meta.env.VITE_AUTH_ENABLED === '1'
)
}
export function authPortalUrl(): string {
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
/\/$/,
'',
)
}
export function redirectToPortalLogin(returnTo?: string) {
const callback =
returnTo ?? `${window.location.origin}/auth/callback`
const url = new URL(authPortalUrl())
url.searchParams.set('return_to', callback)
window.location.href = url.toString()
}
export function parseHashToken(hash: string): {
accessToken: string | null
expiresAt: string | null
} {
const raw = hash.startsWith('#') ? hash.slice(1) : hash
const params = new URLSearchParams(raw)
return {
accessToken: params.get('access_token'),
expiresAt: params.get('expires_at'),
}
}
export function decodeClaims(token: string): AccessClaims | null {
try {
const parts = token.split('.')
if (parts.length < 2) return null
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
const payload = JSON.parse(json) as Record<string, unknown>
return {
sub: String(payload.sub ?? ''),
email: String(payload.email ?? ''),
name: String(payload.name ?? ''),
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
permissions: Array.isArray(payload.permissions)
? payload.permissions.map(String)
: [],
is_admin: Boolean(payload.is_admin),
iss: payload.iss ? String(payload.iss) : undefined,
exp: typeof payload.exp === 'number' ? payload.exp : undefined,
}
} catch {
return null
}
}
export function getClaims(): AccessClaims | null {
const token = getToken()
if (!token) return null
const claims = decodeClaims(token)
if (!claims) return null
if (claims.exp && claims.exp * 1000 < Date.now()) {
clearToken()
return null
}
return claims
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(':')
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === 'read') {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === 'write') {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
export function can(required: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (!claims.apps.includes('vps')) return false
return hasPermission(claims.permissions, required)
}
/** Nav path → minimum permission to show the item. */
export function permissionForPath(pathname: string): string | null {
if (pathname.startsWith('/dashboard')) return 'vps:dashboard:read'
if (
pathname.startsWith('/vps') ||
pathname.startsWith('/tariffs') ||
pathname.startsWith('/projects') ||
pathname.startsWith('/reports') ||
pathname.startsWith('/resources') ||
pathname.startsWith('/renewals')
) {
return 'vps:vps:read'
}
if (pathname.startsWith('/providers') || pathname.startsWith('/accounts')) {
return 'vps:accounts:read'
}
if (pathname.startsWith('/payments') || pathname.startsWith('/balance')) {
return 'vps:payments:read'
}
if (pathname.startsWith('/sync-journal')) return 'vps:sync:write'
if (pathname.startsWith('/settings') || pathname.startsWith('/audit')) {
return 'vps:settings:admin'
}
return 'vps:dashboard:read'
}
export function firstAllowedPath(): string {
const candidates = [
'/dashboard',
'/vps',
'/accounts',
'/payments',
'/sync-journal',
'/settings',
]
for (const path of candidates) {
const perm = permissionForPath(path)
if (!perm || can(perm)) return path
}
return '/dashboard'
}
+21
View File
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthVpsRouteImport } from './routes/_auth/vps'
import { Route as AuthTariffsRouteImport } from './routes/_auth/tariffs'
import { Route as AuthSyncJournalRouteImport } from './routes/_auth/sync-journal'
@@ -39,6 +40,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthCallbackRoute = AuthCallbackRouteImport.update({
id: '/auth/callback',
path: '/auth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthVpsRoute = AuthVpsRouteImport.update({
id: '/vps',
path: '/vps',
@@ -147,6 +153,7 @@ export interface FileRoutesByFullPath {
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRouteWithChildren
'/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
@@ -167,6 +174,7 @@ export interface FileRoutesByTo {
'/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute
'/vps': typeof AuthVpsRouteWithChildren
'/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/vps/$vpsId': typeof AuthVpsVpsIdRoute
@@ -190,6 +198,7 @@ export interface FileRoutesById {
'/_auth/sync-journal': typeof AuthSyncJournalRoute
'/_auth/tariffs': typeof AuthTariffsRoute
'/_auth/vps': typeof AuthVpsRouteWithChildren
'/auth/callback': typeof AuthCallbackRoute
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute
@@ -213,6 +222,7 @@ export interface FileRouteTypes {
| '/sync-journal'
| '/tariffs'
| '/vps'
| '/auth/callback'
| '/projects/$projectId'
| '/settings/integrations'
| '/vps/$vpsId'
@@ -233,6 +243,7 @@ export interface FileRouteTypes {
| '/sync-journal'
| '/tariffs'
| '/vps'
| '/auth/callback'
| '/projects/$projectId'
| '/settings/integrations'
| '/vps/$vpsId'
@@ -255,6 +266,7 @@ export interface FileRouteTypes {
| '/_auth/sync-journal'
| '/_auth/tariffs'
| '/_auth/vps'
| '/auth/callback'
| '/_auth/projects/$projectId'
| '/_auth/settings/integrations'
| '/_auth/vps/$vpsId'
@@ -264,6 +276,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
AuthCallbackRoute: typeof AuthCallbackRoute
}
declare module '@tanstack/react-router' {
@@ -282,6 +295,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/auth/callback': {
id: '/auth/callback'
path: '/auth/callback'
fullPath: '/auth/callback'
preLoaderRoute: typeof AuthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/vps': {
id: '/_auth/vps'
path: '/vps'
@@ -486,6 +506,7 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
AuthCallbackRoute: AuthCallbackRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+31 -1
View File
@@ -1,7 +1,37 @@
import { Outlet, createFileRoute } from '@tanstack/react-router'
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
import { snapshotQueryOptions } from '@/queries/snapshot'
import {
can,
firstAllowedPath,
getClaims,
getToken,
isAuthEnabled,
permissionForPath,
redirectToPortalLogin,
} from '@/lib/auth'
export const Route = createFileRoute('/_auth')({
beforeLoad: ({ location }) => {
if (!isAuthEnabled()) return
const token = getToken()
const claims = getClaims()
if (!token || !claims) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
throw new Error('Redirecting to auth portal')
}
if (!claims.apps.includes('vps')) {
throw redirect({ to: '/' })
}
const perm = permissionForPath(location.pathname)
if (perm && !can(perm)) {
const fallback = firstAllowedPath()
if (fallback !== location.pathname) {
throw redirect({ to: fallback })
}
}
},
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()),
component: AuthLayout,
+23
View File
@@ -0,0 +1,23 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import {
firstAllowedPath,
isAuthEnabled,
parseHashToken,
setToken,
} from '@/lib/auth'
export const Route = createFileRoute('/auth/callback')({
beforeLoad: () => {
if (!isAuthEnabled()) {
throw redirect({ to: '/dashboard' })
}
const { accessToken } = parseHashToken(window.location.hash)
if (!accessToken) {
throw redirect({ to: '/' })
}
setToken(accessToken)
window.history.replaceState(null, '', '/auth/callback')
throw redirect({ to: firstAllowedPath() })
},
component: () => null,
})
+2
View File
@@ -3,6 +3,8 @@
interface ImportMetaEnv {
readonly VITE_API_URL?: string
readonly VITE_APP_SWITCHER?: string
readonly VITE_AUTH_ENABLED?: string
readonly VITE_AUTH_PORTAL_URL?: string
}
interface ImportMeta {