Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cluster/cluster_acl_rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ var clusterACLRules = []ACLRule{
// SLA and Monitoring
{"/actions/reset-sla", nil, []string{config.GrantClusterResetSLA}},
{"/actions/addserver", nil, []string{config.GrantClusterCreateMonitor, config.GrantAppDeployment}},
{"/actions/app-template", nil, []string{config.GrantAppDeployment}},
{"/actions/dropserver", nil, []string{config.GrantClusterDropMonitor}},

// Cluster Actions
Expand Down
64 changes: 64 additions & 0 deletions server/api_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2365,6 +2365,70 @@ func (repman *ReplicationManager) handlerMuxAppRefreshTemplateFromRepo(w http.Re
}
}

// @Summary App Template Preview
// @Description Returns raw app template content and detected defaults for app-host, app-port, and docker image.
// @Tags Apps
// @Accept json
// @Produce json
// @Param Authorization header string true "Insert your access token" default(Bearer <Add access token here>)
// @Param clusterName path string true "Cluster Name"
// @Param templateName path string true "Template Name"
// @Success 200 {object} map[string]interface{} "Template preview payload"
// @Failure 400 {string} string "Template name is required"
// @Failure 403 {string} string "No valid ACL"
// @Failure 404 {string} string "Template not found"
// @Failure 500 {string} string "Unable to parse template"
// @Router /api/clusters/{clusterName}/actions/app-template/{templateName} [get]
func (repman *ReplicationManager) handlerMuxAppTemplatePreview(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
vars := mux.Vars(r)
mycluster := repman.getClusterByName(vars["clusterName"])
if mycluster == nil {
http.Error(w, "No cluster", http.StatusInternalServerError)
return
}

if valid, _ := repman.IsValidClusterACL(r, mycluster); !valid {
http.Error(w, "No valid ACL", http.StatusForbidden)
return
}

templateName := strings.TrimSpace(vars["templateName"])
if templateName == "" {
http.Error(w, "Template name is required", http.StatusBadRequest)
return
}

content, err := mycluster.GetTemplateContent(templateName)
if err != nil {
http.Error(w, fmt.Sprintf("Template not found: %v", err), http.StatusNotFound)
return
}

templateViper, err := mycluster.LoadTemplateToViper(content)
if err != nil {
http.Error(w, fmt.Sprintf("Unable to parse template: %v", err), http.StatusInternalServerError)
return
}

response := map[string]interface{}{
"template": templateName,
"content": string(content),
"defaults": map[string]string{
"host": strings.TrimSpace(templateViper.GetString("app-host")),
"port": strings.TrimSpace(templateViper.GetString("app-port")),
"dockerImage": strings.TrimSpace(templateViper.GetString("prov-app-docker-img")),
},
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
return
}
}

// @Summary Drop App Monitor
// @Description Drops the monitoring configuration for a specific app in a cluster.
// @Tags Apps
Expand Down
4 changes: 4 additions & 0 deletions server/api_cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,10 @@ func (repman *ReplicationManager) apiClusterProtectedHandler(router *mux.Router)
negroni.HandlerFunc(repman.validateTokenMiddleware),
negroni.Wrap(http.HandlerFunc(repman.handlerMuxAppRefreshTemplateFromRepo)),
))
router.Handle("/api/clusters/{clusterName}/actions/app-template/{templateName:.*}", negroni.New(
negroni.HandlerFunc(repman.validateTokenMiddleware),
negroni.Wrap(http.HandlerFunc(repman.handlerMuxAppTemplatePreview)),
))

router.Handle("/api/clusters/{clusterName}/docker/actions/registry-connect", negroni.New(
negroni.HandlerFunc(repman.validateTokenMiddleware),
Expand Down
105 changes: 102 additions & 3 deletions share/dashboard_react/src/components/Modals/NewServerModal.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
Box,
Checkbox,
Flex,
FormControl,
Expand All @@ -13,9 +14,10 @@ import {
ModalFooter,
ModalHeader,
ModalOverlay,
Stack
Stack,
Textarea
} from '@chakra-ui/react'
import { useEffect, useReducer } from 'react'
import { useEffect, useReducer, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { addServer, connectDockerRegistry } from '../../redux/clusterSlice'
import Dropdown from '../Dropdown'
Expand All @@ -27,6 +29,7 @@ import RMIconButton from '../RMIconButton'
import { HiRefresh } from 'react-icons/hi'
import { refreshAppTemplateRepo } from '../../redux/globalClustersSlice'
import PropTypes from 'prop-types'
import { getApi } from '../../services/apiHelper'

const parsePortValue = (value) => {
if (value === undefined || value === null || value === '') return null
Expand Down Expand Up @@ -182,15 +185,80 @@ function NewServerModal({ clusterName, isOpen, closeModal }) {
const dispatch = useDispatch()
const { theme } = useTheme()
const { globalClusters: { monitor, clusters } } = useSelector((state) => state)
const baseURL = useSelector((state) => state?.auth?.baseURL || '')
const [formState, formDispatch] = useReducer(formReducer, initialState)
const { formData, tagOptions, templateOptions, errors } = formState
const { host, port, monitorType, dockerImage, tag, dockerRegistry } = formData
const { private: isPrivateRegistry, url, username, password, authType, template } = dockerRegistry
const isAppMonitor = monitorType === 'app'
const isPortReadOnly = Boolean(monitorType) && !isAppMonitor
const [hostTouched, setHostTouched] = useState(false)
const [portTouched, setPortTouched] = useState(false)
const [templatePreview, setTemplatePreview] = useState({
loading: false,
error: '',
content: '',
defaults: null,
dynamicHost: false,
})
const selectedCluster = clusters?.find((cluster) => cluster?.name === clusterName)
const clusterConfig = selectedCluster?.config || {}

const detectDynamicValue = (value) => {
const val = (value || '').trim()
return val.includes('{{') || val.includes('}}')
}

const fetchTemplatePreview = async (templateName) => {
if (!templateName) {
setTemplatePreview({ loading: false, error: '', content: '', defaults: null, dynamicHost: false })
return
}

setTemplatePreview((prev) => ({ ...prev, loading: true, error: '' }))

try {
const encodedTemplateName = encodeURIComponent(templateName)
const { data, status } = await getApi(baseURL || '').get(`clusters/${clusterName}/actions/app-template/${encodedTemplateName}`)
if (status !== 200) {
throw new Error(typeof data === 'string' ? data : 'Failed to load template preview')
}

const defaults = data?.defaults || {}
const templateHost = (defaults.host || '').trim()
const isDynamicHost = detectDynamicValue(templateHost)

const updates = {}
if (!hostTouched && !host?.trim() && templateHost && !isDynamicHost) {
updates.host = templateHost
}

const defaultPort = parsePortValue(defaults.port)
if (!portTouched && (port === '' || port === null || port === undefined) && defaultPort) {
updates.port = defaultPort
}

const templateDockerImage = (defaults.dockerImage || '').trim()
if (!dockerImage?.trim() && templateDockerImage) {
updates.dockerImage = templateDockerImage
}

if (Object.keys(updates).length > 0) {
formDispatch({ type: 'SET_FORM_DATA', payload: updates })
}

setTemplatePreview({
loading: false,
error: '',
content: data?.content || '',
defaults,
dynamicHost: isDynamicHost,
})
} catch (error) {
setTemplatePreview({ loading: false, error: error?.message || 'Unable to load template preview', content: '', defaults: null, dynamicHost: false })
}
}

const getDefaultPortForType = (type) => {
switch (type) {
case 'mariadb':
Expand Down Expand Up @@ -328,6 +396,9 @@ function NewServerModal({ clusterName, isOpen, closeModal }) {
useEffect(() => {
if (!isOpen) {
formDispatch({ type: 'RESET_FORM' })
setHostTouched(false)
setPortTouched(false)
setTemplatePreview({ loading: false, error: '', content: '', defaults: null, dynamicHost: false })
}
}, [isOpen])

Expand All @@ -348,6 +419,11 @@ function NewServerModal({ clusterName, isOpen, closeModal }) {
const selectedType = option?.value || ''
const defaultPort = getDefaultPortForType(selectedType)
formDispatch({ type: 'FILL_VERSION_DROPDOWN', payload: { type: selectedType, defaultPort } })
if (selectedType !== 'app') {
setHostTouched(false)
setPortTouched(false)
setTemplatePreview({ loading: false, error: '', content: '', defaults: null, dynamicHost: false })
}
}}
options={serviceTypes}
selectedValue={monitorType}
Expand All @@ -365,10 +441,27 @@ function NewServerModal({ clusterName, isOpen, closeModal }) {
<Dropdown
id='template'
isMenuPortalTarget={false}
onChange={(option) => { formDispatch({ type: 'SET_DOCKER_TEMPLATE', payload: option?.value }) }}
onChange={(option) => {
const selectedTemplate = option?.value || ''
formDispatch({ type: 'SET_DOCKER_TEMPLATE', payload: selectedTemplate })
fetchTemplatePreview(selectedTemplate)
}}
options={templateOptions}
selectedValue={template}
/>
{templatePreview.loading && <FormHelperText className={parentStyles.portHintText}>Loading template preview...</FormHelperText>}
{templatePreview.error && <FormHelperText className={parentStyles.templateErrorText}>{templatePreview.error}</FormHelperText>}
{templatePreview.dynamicHost && <FormHelperText className={parentStyles.portHintText}>Template host uses dynamic variables. Please set host manually.</FormHelperText>}
{templatePreview.content && (
<Box className={parentStyles.templatePreviewBox}>
<Textarea
value={templatePreview.content}
readOnly
size='sm'
className={parentStyles.templatePreviewText}
/>
</Box>
)}
</FormControl>

{!template && (
Expand Down Expand Up @@ -411,6 +504,9 @@ function NewServerModal({ clusterName, isOpen, closeModal }) {
isRequired={true}
value={host}
onChange={(e) => {
if (isAppMonitor) {
setHostTouched(true)
}
formDispatch({ type: 'SET_FORM_DATA', payload: { host: e.target.value } })

if (errors.host) {
Expand All @@ -434,6 +530,9 @@ function NewServerModal({ clusterName, isOpen, closeModal }) {
className={isPortReadOnly ? parentStyles.readOnlyPortInput : ''}
onChange={(e) => {
if (isPortReadOnly) return
if (isAppMonitor) {
setPortTouched(true)
}
formDispatch({ type: 'SET_FORM_DATA', payload: { port: e.target.value ? parseInt(e.target.value, 10) : '' } })
if (errors.port) {
formDispatch({ type: 'SET_ERRORS', payload: { port: '' } })
Expand Down
15 changes: 15 additions & 0 deletions share/dashboard_react/src/components/Modals/styles.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,18 @@
color: var(--text-color);
opacity: 0.8;
}

.templateErrorText {
font-size: rem(12);
color: var(--danger-color);
}

.templatePreviewBox {
margin-top: rem(8);
}

.templatePreviewText {
min-height: rem(180);
font-family: monospace;
font-size: rem(12);
}
Loading