deploy.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. import { BlockWrapper } from 'app/components/block-wrapper'
  2. import { BlockByDataInt as BlockByData } from 'app/components/block'
  3. import { NeoButton, Modal } from 'app/design/controls'
  4. import { fetcher } from 'app/lib/fetcher';
  5. import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
  6. import Html from 'app/ui/atoms/html'
  7. import { Row, View } from 'app/design/view';
  8. import { Text } from 'app/design/typography';
  9. import Confirm from 'app/ui/molecules/dialogs/confirm';
  10. import Badge from 'app/ui/molecules/profile/badge';
  11. import emitter, { EVENTS } from 'app/context/emitter';
  12. import Link from 'app/ui/atoms/link';
  13. import { looksLikeHtml } from 'app/lib/util';
  14. import { isFormResponseComplete } from 'app/lib/form/form-helpers';
  15. import { useFocusEffect } from 'app/lib/hooks/router';
  16. import { useTranslation } from 'react-i18next';
  17. function getFormNamesFromBlock(block) {
  18. const content = Array.isArray(block?.content) ? block.content : [];
  19. return content
  20. .filter((item) => item?.type === 'form')
  21. .map((item) => item?.data?.params?.display || item?.name)
  22. .filter((name) => name && !name.includes('_delete'));
  23. }
  24. function shouldCloseAfterFormResponse(responseData) {
  25. return isFormResponseComplete(responseData);
  26. }
  27. function getHostname(url) {
  28. if (!url || typeof url !== 'string') return '';
  29. try {
  30. return new URL(url).hostname.replace(/^www\./, '');
  31. } catch {
  32. return '';
  33. }
  34. }
  35. function getMainDomain(containers) {
  36. if (!Array.isArray(containers) || !containers.length) return '';
  37. const neo = containers.find((container) => /neo/i.test(container?.title || ''));
  38. const neoHost = getHostname(neo?.url);
  39. if (neoHost) return neoHost;
  40. const hosts = containers
  41. .map((container) => getHostname(container?.url))
  42. .filter(Boolean)
  43. .sort((a, b) => a.length - b.length);
  44. return hosts[0] || '';
  45. }
  46. function getStatusBadgeData(text) {
  47. if (!text || typeof text !== 'string') return null;
  48. const normalized = text.trim().toLowerCase();
  49. let color = 'neutral';
  50. if (/(run|active|ready|online|live|ok|success|healthy)/.test(normalized)) {
  51. color = 'green';
  52. } else if (/(fail|error|down|stop|destroy|crash|unhealthy)/.test(normalized)) {
  53. color = 'red';
  54. } else if (/(deploy|start|pend|wait|progress|build|creat|provision|queue)/.test(normalized)) {
  55. color = 'amber';
  56. }
  57. return { text: text.trim(), color };
  58. }
  59. function isDestructiveButton(item) {
  60. return /(tear|delete|destroy|remove|down|deploy)/i.test(item?.title || item?.name || '');
  61. }
  62. export default function ElementDeploy({ data, blockWrapperProps, url }) {
  63. const { t } = useTranslation();
  64. const [blockData, setBlockData] = useState(null);
  65. const [formBlock, setFormBlock] = useState(null);
  66. const [showConfirm, setShowConfirm] = useState(false);
  67. const dataUrlRef = useRef(data?.data_url);
  68. const refreshBlockData = useCallback(async () => {
  69. const dataUrl = dataUrlRef.current;
  70. if (!dataUrl) return;
  71. const sResponse = await fetcher(`/api.php?r=${dataUrl}`);
  72. setBlockData(sResponse?.data[0]?.data);
  73. }, []);
  74. useEffect(() => {
  75. refreshBlockData();
  76. }, [refreshBlockData]);
  77. useFocusEffect(
  78. useCallback(() => {
  79. refreshBlockData();
  80. }, [refreshBlockData])
  81. );
  82. useEffect(() => {
  83. dataUrlRef.current = blockData?.data_url ?? data?.data_url ?? dataUrlRef.current;
  84. }, [blockData?.data_url, data?.data_url]);
  85. const handleOpenDeployForm = async (item) => {
  86. if (item.form_url) {
  87. const sResponse = await fetcher(`/api.php?r=${item.form_url}`);
  88. setFormBlock({ content: sResponse.data, designbox_id: 0, title: t('Deployment settings') });
  89. }
  90. if (item.request_url) {
  91. setShowConfirm(`/api.php?r=${item.request_url}`)
  92. }
  93. };
  94. const handleCloseDeployForm = useCallback(() => {
  95. setFormBlock(null);
  96. refreshBlockData();
  97. }, [refreshBlockData]);
  98. useEffect(() => {
  99. if (!formBlock) return;
  100. const formNames = getFormNamesFromBlock(formBlock);
  101. if (!formNames.length) return;
  102. const subscriptions = formNames.map((formName) =>
  103. emitter.addListener(EVENTS.form(formName), (event) => {
  104. if (event.action !== 'received') return;
  105. if (!shouldCloseAfterFormResponse(event.data)) return;
  106. handleCloseDeployForm();
  107. })
  108. );
  109. return () => subscriptions.forEach((subscription) => subscription.remove());
  110. }, [formBlock, handleCloseDeployForm]);
  111. useEffect(() => {
  112. if (!blockData?.text || blockData?.buttons.length > 0 || !blockData.data_url) return;
  113. const dataUrl = blockData.data_url;
  114. const poll = async () => {
  115. const sResponse = await fetcher(`/api.php?r=${dataUrl}`);
  116. setBlockData(sResponse?.data[0]?.data);
  117. };
  118. poll();
  119. const intervalId = setInterval(poll, 5000);
  120. return () => clearInterval(intervalId);
  121. }, [blockData?.text, blockData?.data_url]);
  122. const mainDomain = useMemo(
  123. () => getMainDomain(blockData?.containers),
  124. [blockData?.containers]
  125. );
  126. const statusBadge = useMemo(
  127. () => getStatusBadgeData(blockData?.text),
  128. [blockData?.text]
  129. );
  130. const buttons = Array.isArray(blockData?.buttons) ? blockData.buttons : [];
  131. const containers = Array.isArray(blockData?.containers) ? blockData.containers : [];
  132. return (
  133. <BlockWrapper {...blockWrapperProps}>
  134. {(!!statusBadge || buttons.length > 0) && (
  135. <View className="w-full gap-3 w-full">
  136. {(!!statusBadge && looksLikeHtml(statusBadge.text)) && (
  137. <View className={`bg-${statusBadge.color}-600/20 p-3 text-center rounded-full w-full gap-2`}>
  138. <Html data={statusBadge.text} />
  139. </View>
  140. )}
  141. {(!!statusBadge && !looksLikeHtml(statusBadge.text)) && (
  142. <Row className="gap-2 items-center">
  143. <Text>{t('Status:')}</Text><Badge
  144. size="sm"
  145. rounded
  146. data={statusBadge}
  147. /></Row>
  148. )}
  149. {buttons.length > 0 && (
  150. <Row className="items-center gap-2 flex-wrap justify-start">
  151. {buttons.map((item, index) => {
  152. const destructive = isDestructiveButton(item);
  153. const buttonStyle = destructive || item?.primary ? 'borderedProminent' : 'bordered';
  154. const buttonLabel = String(item?.title || item?.name || '').trim();
  155. if (!buttonLabel) return null;
  156. return (
  157. <NeoButton
  158. key={item.id || item.name || buttonLabel || `deploy-button-${index}`}
  159. buttonStyle={destructive ? 'borderedProminent' : 'bordered'}
  160. controlSize={buttons.length > 1 ? "small" : "xlarge"}
  161. role={destructive ? 'destructive' : undefined}
  162. accessibilityLabel={buttonLabel}
  163. onPress={() => handleOpenDeployForm(item)}
  164. >
  165. <Text
  166. className={
  167. destructive
  168. ? 'text-sm font-medium text-destructive-foreground'
  169. : 'text-sm font-medium text-secondary-foreground'
  170. }
  171. >
  172. {buttonLabel}
  173. </Text>
  174. </NeoButton>
  175. );
  176. })}
  177. </Row>
  178. )}
  179. </View>
  180. )}
  181. {containers.length > 0 && (
  182. <View className="mt-1 gap-2">
  183. {containers.map((container, index) => (
  184. <View
  185. key={`container-${index}`}
  186. className="gap-1 rounded-xl border border-border/50 bg-muted/30 px-3 py-2.5"
  187. >
  188. <Row className="items-start justify-between gap-3 flex-wrap">
  189. {!!container?.title && (
  190. <Text className="text-sm text-muted-foreground shrink-0 pt-0.5">
  191. {container.title}
  192. </Text>
  193. )}
  194. {!!container?.url && (
  195. <Link
  196. href={container.url}
  197. asExternal
  198. mode="text"
  199. className="text-sm font-medium text-primary web:hover:underline min-w-0 flex-1 text-right"
  200. >
  201. {container.url}
  202. </Link>
  203. )}
  204. </Row>
  205. {!!container?.info && (
  206. <Text className="text-xs text-muted-foreground leading-5">
  207. {container.info}
  208. </Text>
  209. )}
  210. </View>
  211. ))}
  212. </View>
  213. )}
  214. {!!formBlock && (
  215. <Modal scrollable={true} title={formBlock.title || t('Deployment settings')} onVisible={!!formBlock} onClose={handleCloseDeployForm}>
  216. <BlockByData block={formBlock} onFormEmpty={handleCloseDeployForm} />
  217. </Modal>
  218. )}
  219. <Confirm onVisible={showConfirm} title={t('Server will be removed with all data! Are you sure to proceed ?')} handleCancel={() => setShowConfirm(false)} handleOk={async () => { await fetcher(showConfirm); setShowConfirm(false); handleCloseDeployForm() }} />
  220. </BlockWrapper>
  221. );
  222. }