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
+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'
}