Skip to content

Commit

Permalink
Add project readonly configuration view for SSM
Browse files Browse the repository at this point in the history
This adds the first of hopefully many views for the project
configuration tab. This checks to see if the project uses the SSM
configuration type and if so, will fetch SSM params for this project
and display them in a table similar to Operations Log.
  • Loading branch information
in-op committed Mar 5, 2024
1 parent 6ab775e commit 87102c6
Show file tree
Hide file tree
Showing 8 changed files with 325 additions and 33 deletions.
4 changes: 4 additions & 0 deletions public/locales/en.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export default {
en: {
translation: {
aws: {
ssmParameter: 'SSM Parameter'
},
common: {
all: 'All',
cancel: 'Cancel',
Expand Down Expand Up @@ -44,6 +47,7 @@ export default {
textClass: 'Text Class',
turnOff: 'Turn Off',
turnOn: 'Turn On',
type: 'Type',
updatedBy: 'Updated By',
value: 'Value',
welcome: 'Welcome'
Expand Down
31 changes: 0 additions & 31 deletions src/js/views/Project/Configuration.jsx

This file was deleted.

40 changes: 40 additions & 0 deletions src/js/views/Project/Configuration/Configuration.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { useContext, useEffect } from 'react'
import { Context } from '../../../state'
import PropTypes from 'prop-types'
import { SSMConfiguration } from './SSMConfiguration'
import { WishedFutureState } from '../../../components'

function Configuration({ urlPath, project }) {
const [globalState, dispatch] = useContext(Context)

useEffect(() => {
dispatch({
type: 'SET_CURRENT_PAGE',
payload: {
title: 'common.configuration',
url: new URL(`${urlPath}/configuration`, globalState.baseURL)
}
})
}, [])

switch (project.configuration_type) {
case 'ssm':
return <SSMConfiguration project={project} />
default:
return (
<div className="pt-20 flex items-center justify-center">
<WishedFutureState>
This tab will provide an abstracted interface for editing the
configuration in Consul, Vault, AWS SSM Parameter Store, and K8s
Configuration Maps.
</WishedFutureState>
</div>
)
}
}

Configuration.propTypes = {
urlPath: PropTypes.string.isRequired,
project: PropTypes.object.isRequired
}
export { Configuration }
34 changes: 34 additions & 0 deletions src/js/views/Project/Configuration/DisplaySSMParam.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import { DescriptionList } from '../../../components/DescriptionList/DescriptionList'
import { Definition } from '../../../components/DescriptionList/Definition'

function DisplaySSMParam({ param }) {
const { t } = useTranslation()

return (
<>
<DescriptionList>
<Definition term={t('common.name')}>{param.name}</Definition>
<Definition term={t('common.type')}>{param.type}</Definition>
</DescriptionList>
<h1 className="text-xl font-medium text-gray-900 mt-6 mb-3">Values</h1>
<DescriptionList>
{param.values.map(({ environment, value }, i) => {
return (
<Definition key={i} className="break-words" term={environment}>
{value}
</Definition>
)
})}
</DescriptionList>
</>
)
}

DisplaySSMParam.propTypes = {
param: PropTypes.object.isRequired
}

export { DisplaySSMParam }
47 changes: 47 additions & 0 deletions src/js/views/Project/Configuration/DisplaySecureStringParam.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import { DescriptionList } from '../../../components/DescriptionList/DescriptionList'
import { Definition } from '../../../components/DescriptionList/Definition'
import { Toggle } from '../../../components/Form/Toggle'

function DisplaySecureStringParam({ param }) {
const { t } = useTranslation()
const [isShown, setIsShown] = useState(false)

return (
<>
<DescriptionList>
<Definition term={t('common.name')}>{param.name}</Definition>
<Definition term={t('common.type')}>{param.type}</Definition>
</DescriptionList>
<div className="flex items-center justify-between mt-6 mb-3">
<h1 className="text-xl font-medium text-gray-900">Values</h1>
<div className="flex items-center gap-1">
<p>Show decrypted value</p>
<Toggle
onChange={(name, value) => setIsShown(value)}
name="is-hidden"
value={isShown}
/>
</div>
</div>

<DescriptionList>
{param.values.map(({ environment, value }, i) => {
return (
<Definition key={i} className="break-words" term={environment}>
{isShown ? value : '********'}
</Definition>
)
})}
</DescriptionList>
</>
)
}

DisplaySecureStringParam.propTypes = {
param: PropTypes.object.isRequired
}

export { DisplaySecureStringParam }
180 changes: 180 additions & 0 deletions src/js/views/Project/Configuration/SSMConfiguration.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import PropTypes from 'prop-types'
import React, { useContext, useEffect, useRef, useState } from 'react'

import { Context } from '../../../state'
import { Alert, Icon, Loading, Table } from '../../../components'
import { useTranslation } from 'react-i18next'
import { SlideOver } from '../../../components/SlideOver/SlideOver'
import { ViewSSMParam } from './ViewSSMParam'
import { useSearchParams } from 'react-router-dom'
import { httpGet } from '../../../utils'

function cloneParams(searchParams) {
const newParams = new URLSearchParams()
for (const [key, value] of searchParams) {
newParams.set(key, value)
}
return newParams
}

function SSMConfiguration({ project }) {
const [globalState, dispatch] = useContext(Context)
const { t } = useTranslation()
const [searchParams, setSearchParams] = useSearchParams()
const [onFetch, setOnFetch] = useState(true)
const [fetching, setFetching] = useState(false)
const [errorMessage, setErrorMessage] = useState()
const [rows, setRows] = useState([])
const [slideOverOpen, setSlideOverOpen] = useState(false)
const [selectedIndex, setSelectedIndex] = useState()
const [slideOverFocusTrigger, setSlideOverFocusTrigger] = useState({})
const [showArrows, setShowArrows] = useState(false)
const arrowLeftRef = useRef(null)
const arrowRightRef = useRef(null)

if (searchParams.get('v') && !slideOverOpen && rows.length > 0) {
setSlideOverOpen(true)
setShowArrows(true)
setSelectedIndex(
rows.findIndex((row) => row.name === searchParams.get('v'))
)
} else if (!searchParams.get('v') && slideOverOpen) {
setSlideOverOpen(false)
setShowArrows(false)
}

function move(index) {
setSelectedIndex(index)
const newParams = cloneParams(searchParams)
newParams.set('v', rows[index].name)
setSearchParams(newParams)
}

useEffect(() => {
if (fetching || !onFetch) return
setFetching(true)

httpGet(
globalState.fetch,
new URL(`/projects/${project.id}/configuration/ssm`, globalState.baseURL),
({ data }) => {
setRows(data.sort((a, b) => (a.name > b.name ? 1 : -1)))
setFetching(false)
},
(message) => {
setErrorMessage(message)
setFetching(false)
}
)
setOnFetch(false)
}, [onFetch])

if (fetching) return <Loading></Loading>
if (errorMessage) return <Alert level="error">{errorMessage}</Alert>

return (
<div className="m-0">
<Table
columns={[
{
title: t('common.name'),
name: 'name',
type: 'text',
tableOptions: {
headerClassName: 'w-10/12'
}
},
{
title: t('common.type'),
name: 'type',
type: 'text',
tableOptions: {
className: 'truncate'
}
}
]}
data={rows}
onRowClick={({ index }) => {
const newParams = cloneParams(searchParams)
newParams.set('v', rows[index].name)
setSearchParams(newParams)
setSlideOverOpen(true)
setSelectedIndex(index)
setShowArrows(true)
}}
checkIsHighlighted={(row) => row.name === searchParams.get('v')}
/>
<SlideOver
open={slideOverOpen}
title={
<div className="flex items-center">
{t('aws.ssmParameter')}
{selectedIndex !== undefined && showArrows && (
<>
<button
ref={arrowLeftRef}
className="ml-4 mr-2 h-min outline-offset-4"
onClick={() => {
if (selectedIndex > 0) move(selectedIndex - 1)
}}
tabIndex={selectedIndex === 0 ? -1 : 0}>
<Icon
icon="fas arrow-left"
className={
'h-4 select-none block' +
(selectedIndex === 0 ? ' text-gray-200' : '')
}
/>
</button>

<button
ref={arrowRightRef}
className="outline-offset-4"
onClick={() => {
if (selectedIndex < rows.length - 1) move(selectedIndex + 1)
}}
tabIndex={selectedIndex === rows.length - 1 ? -1 : 0}>
<Icon
icon="fas arrow-right"
className={
'h-4 select-none block' +
(selectedIndex === rows.length - 1
? ' text-gray-200'
: '')
}
/>
</button>
</>
)}
</div>
}
focusTrigger={slideOverFocusTrigger}
onClose={() => {
const newParams = cloneParams(searchParams)
newParams.delete('v')
setSearchParams(newParams)
setSlideOverOpen(false)
}}
onKeyDown={(e) => {
if (!showArrows) return
if (selectedIndex > 0 && e.key === 'ArrowLeft') {
arrowLeftRef.current?.focus()
move(selectedIndex - 1)
} else if (
selectedIndex < rows.length - 1 &&
e.key === 'ArrowRight'
) {
arrowRightRef.current?.focus()
move(selectedIndex + 1)
}
}}>
<ViewSSMParam param={rows[selectedIndex]} />
</SlideOver>
</div>
)
}

SSMConfiguration.propTypes = {
project: PropTypes.object.isRequired
}
export { SSMConfiguration }
18 changes: 18 additions & 0 deletions src/js/views/Project/Configuration/ViewSSMParam.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react'
import PropTypes from 'prop-types'
import { DisplaySSMParam } from './DisplaySSMParam'
import { DisplaySecureStringParam } from './DisplaySecureStringParam'

function ViewSSMParam({ param }) {
if (param.type === 'SecureString') {
return <DisplaySecureStringParam param={param} />
} else {
return <DisplaySSMParam param={param} />
}
}

ViewSSMParam.propTypes = {
param: PropTypes.object
}

export { ViewSSMParam }
4 changes: 2 additions & 2 deletions src/js/views/Project/Project.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ import {
Tooltip
} from '../../components'

import { Configuration } from './Configuration'
import { Dependencies } from './Dependencies'
import { Logs } from './Logs'
import { Notes } from './Notes'
import { Overview } from './Overview'
import { Settings } from './Settings'
import { OperationsLog } from '../OperationsLog'
import { Configuration } from './Configuration/Configuration'

function ProjectPage({ project, factTypes, refresh }) {
const [state, dispatch] = useContext(Context)
Expand Down Expand Up @@ -132,7 +132,7 @@ function ProjectPage({ project, factTypes, refresh }) {
/>
<Route
path={`configuration`}
element={<Configuration urlPath={baseURL} />}
element={<Configuration project={project} urlPath={baseURL} />}
/>
<Route
path={`dependencies`}
Expand Down

0 comments on commit 87102c6

Please sign in to comment.