This commit is contained in:
typescreep
2025-05-29 21:51:44 +03:00
parent 15835e97c3
commit 76ca4b344b
31 changed files with 558 additions and 15 deletions

29
src/api/auth.ts Normal file
View File

@@ -0,0 +1,29 @@
import axios, { AxiosResponse } from 'axios'
import { TAuthResponse } from 'localTypes/auth'
import { handleError } from './handleResponse'
export const login = async (): Promise<TAuthResponse | undefined> => {
let response: AxiosResponse<TAuthResponse> | undefined
try {
response = await axios.get<TAuthResponse>('/oauth/token', { withCredentials: true })
} catch (error) {
handleError(error)
}
return response?.data
}
export const logout = async (): Promise<TAuthResponse | undefined> => {
let response: AxiosResponse<TAuthResponse> | undefined
try {
response = await axios.get<TAuthResponse>('/oauth/logout', { withCredentials: true })
} catch (error) {
handleError(error)
} finally {
window.location.reload()
}
return response?.data
}

20
src/api/handleResponse.ts Normal file
View File

@@ -0,0 +1,20 @@
import axios, { isAxiosError } from 'axios'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const handleError = (error: any) => {
if (isAxiosError(error)) {
if (error.status === 401) {
try {
axios.get('/oauth/logout', {
method: 'GET',
withCredentials: true,
})
} finally {
window.location.reload()
}
}
throw new Error(`Request failed with status ${error.status}: ${error.message}`)
} else {
throw new Error('Non axios error')
}
}

View File

@@ -0,0 +1,45 @@
import React, { FC } from 'react'
import { Row, Col } from 'antd'
import { useParams } from 'react-router-dom'
import { AccessGroups, Documentation, Logo, ManageableSidebar, Selector, User } from './organisms'
export const Header: FC = () => {
const { projectName, instanceName, clusterName, entryType, namespace, syntheticProject } = useParams()
const possibleProject = syntheticProject && namespace ? syntheticProject : namespace
const possibleInstance = syntheticProject && namespace ? namespace : undefined
return (
<Row>
<Col span={2}>
<Logo />
</Col>
<Col span={8}>
<Selector
clusterName={clusterName}
projectName={projectName || possibleProject}
instanceName={instanceName || possibleInstance}
/>
</Col>
<Col span={8}>
<ManageableSidebar
clusterName={clusterName}
entryType={entryType}
instanceName={instanceName}
projectName={projectName}
/>
</Col>
<Col span={2}>
{instanceName && projectName && (
<AccessGroups clusterName={clusterName} instanceName={instanceName} projectName={projectName} />
)}
</Col>
<Col span={2}>
<Documentation key="SidebarDocumentation" />
</Col>
<Col span={2}>
<User key="SidebarUser" />
</Col>
</Row>
)
}

View File

@@ -0,0 +1 @@
export * from './Header'

View File

@@ -0,0 +1,46 @@
import React, { FC, useCallback } from 'react'
import { notification } from 'antd'
import { FireOutlined } from '@ant-design/icons'
import { useSelector } from 'react-redux'
import { RootState } from 'store/store'
import { Styled } from './styled'
type TAccessGroupsProps = {
clusterName: string | undefined
instanceName: string
projectName: string
}
export const AccessGroups: FC<TAccessGroupsProps> = ({ clusterName, projectName, instanceName }) => {
const [api, contextHolder] = notification.useNotification()
const clusterList = useSelector((state: RootState) => state.clusterList.clusterList)
const cluster = clusterList ? clusterList.find(({ name }) => name === clusterName) : undefined
const clusterTenant = cluster?.tenant || ''
const shortName = instanceName.startsWith(`${projectName}-`)
? instanceName.substring(`${projectName}-`.length)
: instanceName
const value = `${projectName}:${shortName}:${clusterTenant}`
const renderValue = value.length > 37 ? `${value.slice(0, 34)}...` : value
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(value)
api.success({
message: 'Access Group copied:',
description: value,
key: 'copy-success',
})
}, [value, api])
return (
<>
{contextHolder}
<Styled.FullWidthButton type="text" icon={<FireOutlined />} onClick={handleCopy}>
{renderValue}
</Styled.FullWidthButton>
</>
)
}

View File

@@ -0,0 +1 @@
export * from './AccessGroups'

View File

@@ -0,0 +1,15 @@
import styled from 'styled-components'
import { Button } from 'antd'
const FullWidthButton = styled(Button)`
width: 100%;
display: flex;
justify-content: flex-start;
cursor: pointer;
color: ${({ theme }) => theme.colorText};
padding-left: 16px;
`
export const Styled = {
FullWidthButton,
}

View File

@@ -0,0 +1,14 @@
import React, { FC } from 'react'
import { FileTextOutlined } from '@ant-design/icons'
import { Styled } from './styled'
export const Documentation: FC = () => {
const platformDocumentationUrl = '/docs'
return (
<Styled.FullWidthButton type="text" onClick={() => window.open(platformDocumentationUrl, '_blank')}>
<FileTextOutlined />
Documentaion
</Styled.FullWidthButton>
)
}

View File

@@ -0,0 +1 @@
export * from './Documentation'

View File

@@ -0,0 +1,12 @@
import styled from 'styled-components'
import { Button } from 'antd'
const FullWidthButton = styled(Button)`
width: 100%;
display: flex;
justify-content: flex-start;
`
export const Styled = {
FullWidthButton,
}

View File

@@ -0,0 +1,19 @@
import React, { FC } from 'react'
import { useNavigate } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { RootState } from 'store/store'
import { TitleWithNoTopMargin } from 'components/atoms'
import { Styled } from './styled'
export const Logo: FC = () => {
const navigate = useNavigate()
const baseprefix = useSelector((state: RootState) => state.baseprefix.baseprefix)
return (
<Styled.CursorPointer>
<TitleWithNoTopMargin level={2} onClick={() => navigate(`${baseprefix}`)}>
InCloud
</TitleWithNoTopMargin>
</Styled.CursorPointer>
)
}

View File

@@ -0,0 +1 @@
export * from './Logo'

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components'
const CursorPointer = styled.div`
cursor: pointer;
`
export const Styled = {
CursorPointer,
}

View File

@@ -0,0 +1,44 @@
import React, { FC } from 'react'
import { useLocation, useParams } from 'react-router-dom'
import { ManageableSidebarWithDataProvider } from '@prorobotech/openapi-k8s-toolkit'
import { BASE_API_GROUP, BASE_API_VERSION } from 'constants/customizationApiGroupAndVersion'
type TManageableSidebarProps = {
clusterName?: string
entryType?: string
instanceName?: string
projectName?: string
}
export const ManageableSidebar: FC<TManageableSidebarProps> = ({
clusterName,
projectName,
instanceName,
entryType,
}) => {
const { pathname } = useLocation()
const params = useParams()
const namespace = params?.namespace || ''
const syntheticProject = params?.syntheticProject || ''
const creating = projectName === 'create' || instanceName === 'create'
const updating = entryType === 'update'
const visible = !creating && !updating
return (
<ManageableSidebarWithDataProvider
uri={`/api/clusters/${clusterName}/k8s/apis/${BASE_API_GROUP}/${BASE_API_VERSION}/sidebars/`}
refetchInterval={5000}
isEnabled={clusterName !== undefined}
replaceValues={{
clusterName,
projectName,
instanceName,
namespace,
syntheticProject,
}}
pathname={pathname}
hidden={!visible}
/>
)
}

View File

@@ -0,0 +1 @@
export * from './ManageableSidebar'

View File

@@ -0,0 +1,87 @@
import React, { FC, useState } from 'react'
import { Col, Row } from 'antd'
import { useNavigate } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { RootState } from 'store/store'
import { useNavSelector } from 'hooks/useNavSelector'
import { useMountEffect } from 'hooks/useMountEffect'
import { EntrySelect } from './molecules'
type TSelectorProps = {
clusterName?: string
projectName?: string
instanceName?: string
}
export const Selector: FC<TSelectorProps> = ({ clusterName, projectName, instanceName }) => {
const navigate = useNavigate()
const baseprefix = useSelector((state: RootState) => state.baseprefix.baseprefix)
const [selectedClusterName, setSelectedClusterName] = useState(clusterName)
const [selectedProjectName, setSelectedProjectName] = useState(projectName)
const [selectedInstanceName, setSelectedInstanceName] = useState(instanceName)
const { projectsInSidebar, instancesInSidebar, allInstancesLoadingSuccess, clustersInSidebar } = useNavSelector(
selectedClusterName,
projectName,
)
const handleClusterChange = (value: string) => {
setSelectedClusterName(value)
navigate(`${baseprefix}/clusters/${value}`)
setSelectedProjectName(undefined)
setSelectedInstanceName(undefined)
}
const handleProjectChange = (value: string) => {
setSelectedProjectName(value)
setSelectedInstanceName(undefined)
navigate(`${baseprefix}/clusters/${selectedClusterName}/projects/${value}`)
}
const handleInstanceChange = (value: string) => {
setSelectedInstanceName(value)
navigate(`${baseprefix}/${selectedClusterName}/${value}/${selectedProjectName}/non-crd-table/apps/v1/deployments`)
}
useMountEffect(() => {
setSelectedClusterName(clusterName)
setSelectedProjectName(projectName)
setSelectedInstanceName(instanceName)
}, [projectName, instanceName, clusterName])
return (
<Row gutter={[16, 16]}>
<Col span={8}>
<EntrySelect
placeholder="Cluster"
options={clustersInSidebar}
value={selectedClusterName}
onChange={handleClusterChange}
/>
</Col>
<Col span={8}>
<EntrySelect
placeholder="Project"
options={projectsInSidebar}
value={selectedProjectName}
onChange={handleProjectChange}
disabled={selectedClusterName === undefined || projectsInSidebar.length === 0}
/>
</Col>
<Col span={8}>
<EntrySelect
placeholder="Intance"
options={instancesInSidebar}
value={selectedInstanceName}
onChange={handleInstanceChange}
disabled={
selectedClusterName === undefined ||
selectedProjectName === undefined ||
(allInstancesLoadingSuccess && instancesInSidebar.length === 0)
}
/>
</Col>
</Row>
)
}

View File

@@ -0,0 +1 @@
export * from './Selector'

View File

@@ -0,0 +1,26 @@
import React, { FC } from 'react'
import { Select } from 'antd'
type TEntrySelectProps = {
placeholder: string
options: {
label: string
value: string
}[]
value?: string
onChange: (val: string) => void
disabled?: boolean
}
export const EntrySelect: FC<TEntrySelectProps> = ({ placeholder, value, disabled, options, onChange }) => {
return (
<Select
placeholder={placeholder}
value={value || ''}
options={options.map(({ value, label }) => ({ label, value }))}
onChange={(selectedValue: string) => onChange(selectedValue)}
disabled={disabled}
style={{ width: '100%' }}
/>
)
}

View File

@@ -0,0 +1 @@
export * from './EntrySelect'

View File

@@ -0,0 +1 @@
export * from './EntrySelect'

View File

@@ -0,0 +1,44 @@
import React, { FC } from 'react'
import { Tooltip, Dropdown } from 'antd'
import { LogoutOutlined } from '@ant-design/icons'
import { ThemeSelector } from 'components'
import { useAuth } from 'hooks/useAuth'
import { logout } from 'api/auth'
import { Styled } from './styled'
export const User: FC = () => {
const { fullName } = useAuth()
return (
<Dropdown
placement="top"
menu={{
items: [
{
key: '1',
label: <ThemeSelector />,
},
{
key: '2',
label: (
<div onClick={() => logout()}>
<LogoutOutlined /> Logout
</div>
),
},
],
}}
trigger={['click']}
>
<Styled.FullWidthButton type="text">
{fullName && fullName.length > 25 ? (
<Tooltip title={fullName}>
<Styled.Name>{fullName.slice(25)}</Styled.Name>
</Tooltip>
) : (
<Styled.Name>{fullName || 'User'}</Styled.Name>
)}
</Styled.FullWidthButton>
</Dropdown>
)
}

View File

@@ -0,0 +1 @@
export * from './User'

View File

@@ -0,0 +1,19 @@
import styled from 'styled-components'
import { Button } from 'antd'
const FullWidthButton = styled(Button)`
width: 100%;
display: flex;
justify-content: flex-start;
`
const Name = styled.span`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`
export const Styled = {
FullWidthButton,
Name,
}

View File

@@ -0,0 +1,6 @@
export * from './Logo'
export * from './Selector'
export * from './ManageableSidebar'
export * from './AccessGroups'
export * from './Documentation'
export * from './User'

View File

@@ -10,3 +10,4 @@ export * from './TableNonCrdInfo'
export * from './TableBuiltinInfo'
export * from './Forms'
export * from './Factory'
export * from './Header'

35
src/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,35 @@
import { useState, useEffect } from 'react'
import { login } from 'api/auth'
export const useAuth = () => {
const [fullName, setFullName] = useState<string>()
const [requester, setRequester] = useState<{ name: string; email: string }>()
const [loadingAuth, setLoadingAuth] = useState(false)
const [error, setError] = useState<string>()
useEffect(() => {
setLoadingAuth(true)
if (!fullName || !requester) {
login()
.then(data => {
if (data) {
setFullName(data.name)
setRequester({ name: data.name, email: data.email })
setLoadingAuth(false)
}
})
.catch(err => setError(err instanceof Error ? err.message : 'Unknown error'))
.finally(() => setLoadingAuth(false))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (import.meta.env.VITE_HAS_MOCKS === 'true') {
return {
fullName: 'John Doe',
requester: { name: 'John Doe', email: 'cK5mH@example.com', loadingAuth: false, error: undefined },
}
}
return { fullName, requester, loadingAuth, error }
}

View File

@@ -0,0 +1,17 @@
import { useEffect, useRef, DependencyList, EffectCallback } from 'react'
export const useMountEffect = (fn: EffectCallback, deps: DependencyList) => {
const isMount = useRef(true)
const fnRef = useRef<EffectCallback>(fn)
fnRef.current = fn
useEffect(() => {
if (isMount.current) {
isMount.current = false
return undefined
}
return fnRef.current()
}, deps) // eslint-disable-line react-hooks/exhaustive-deps
}

View File

@@ -0,0 +1,45 @@
import { useApiResources, TClusterList, TSingleResource } from '@prorobotech/openapi-k8s-toolkit'
import { useSelector } from 'react-redux'
import { RootState } from 'store/store'
import { BASE_API_GROUP, BASE_RPROJECTS_VERSION } from 'constants/customizationApiGroupAndVersion'
const mappedClusterToOptionInSidebar = ({ name }: TClusterList[number]): { value: string; label: string } => ({
value: name,
label: name,
})
const mappedToOptionInSidebar = ({ metadata }: TSingleResource): { value: string; label: string } => ({
value: metadata.name,
label: metadata.name,
})
export const useNavSelector = (clusterName?: string, projectName?: string) => {
const clusterList = useSelector((state: RootState) => state.clusterList.clusterList)
const { data: projects } = useApiResources({
clusterName: clusterName || '',
namespace: '',
apiGroup: BASE_API_GROUP,
apiVersion: BASE_RPROJECTS_VERSION,
typeName: 'projects',
limit: null,
})
const { data: instances, isSuccess: allInstancesLoadingSuccess } = useApiResources({
clusterName: clusterName || '',
namespace: '',
apiGroup: BASE_API_GROUP,
apiVersion: BASE_RPROJECTS_VERSION,
typeName: 'instances',
limit: null,
})
const clustersInSidebar = clusterList ? clusterList.map(mappedClusterToOptionInSidebar) : []
const projectsInSidebar = clusterName && projects ? projects.items.map(mappedToOptionInSidebar) : []
const instancesInSidebar =
clusterName && instances
? instances.items.filter(item => item.metadata.namespace === projectName).map(mappedToOptionInSidebar)
: []
return { clustersInSidebar, projectsInSidebar, instancesInSidebar, allInstancesLoadingSuccess }
}

12
src/localTypes/auth.ts Normal file
View File

@@ -0,0 +1,12 @@
export type TAuthResponse = {
at_hash: string
aud: string
email: string
email_verified: boolean
exp: number
groups: string[]
iat: number
iss: string
name: string
sub: string
}

View File

@@ -7,7 +7,7 @@ import type { RootState } from 'store/store'
import { setTheme } from 'store/theme/theme/theme'
import { setCluster } from 'store/cluster/cluster/cluster'
import { setClusterList } from 'store/clusterList/clusterList/clusterList'
import { DefaultLayout, DefaultColorProvider, TitleWithNoTopMargin, ThemeSelector } from 'components'
import { DefaultLayout, DefaultColorProvider, Header } from 'components'
import { Styled } from './styled'
type TBaseTemplateProps = {
@@ -85,14 +85,9 @@ export const BaseTemplate: FC<TBaseTemplateProps> = ({ children, withNoCluster,
<DefaultLayout.Layout $bgColor={token.colorBgLayout}>
<DefaultLayout.ContentContainer>
<DefaultLayout.ContentPadding $isFederation={isFederation}>
{!isFederation && (
<Styled.TitleAndThemeToggle>
<TitleWithNoTopMargin level={1}>OpenAPI UI</TitleWithNoTopMargin>
{clusterListQuery.error && (
<Alert message={`Cluster List Error: ${clusterListQuery.error?.message} `} type="error" />
)}
<ThemeSelector />
</Styled.TitleAndThemeToggle>
<Header />
{clusterListQuery.error && (
<Alert message={`Cluster List Error: ${clusterListQuery.error?.message} `} type="error" />
)}
{children}
</DefaultLayout.ContentPadding>

View File

@@ -8,12 +8,6 @@ const Container = styled.div<TContainerProps>`
min-height: 100vh;
`
const TitleAndThemeToggle = styled.div`
display: flex;
justify-content: space-between;
width: 100%;
`
export const Styled = {
Container,
TitleAndThemeToggle,
}