diff --git a/src/hooks/useApps.tsx b/src/hooks/useApps.tsx index 71927a643..9b73a5285 100644 --- a/src/hooks/useApps.tsx +++ b/src/hooks/useApps.tsx @@ -130,5 +130,18 @@ export const useApps = () => { }; }; - return { tableColumns, tableData, metaData }; + const showTitle = false; + const tableTitle = "Apps Summary"; + const graphTitle = "Apps Graph"; + const graphApi = `/apps?series=true`; + + return { + tableColumns, + tableData, + metaData, + showTitle, + tableTitle, + graphTitle, + graphApi, + }; }; diff --git a/src/pages/Apps/AppSettingsPage.tsx b/src/pages/Apps/AppSettingsPage.tsx index b9d9af6fd..e63326c81 100644 --- a/src/pages/Apps/AppSettingsPage.tsx +++ b/src/pages/Apps/AppSettingsPage.tsx @@ -27,42 +27,46 @@ import { Stack, Tabs, Text, + Table as MantineTable, Tooltip, TextInput, - Table as MantineTable, Alert, - ActionIcon, - CopyButton, - Paper, Modal, + Box, + Paper, + Anchor, + ActionIcon, + Collapse, Code, + CopyButton, List, - Collapse, - Box, } from "@mantine/core"; import { useContext, useEffect, useState } from "react"; import { + HiCheck, HiLockClosed, HiLockOpen, HiOutlineXCircle, HiPlus, HiTrash, - HiCheck as IconCheck, HiExclamationTriangle as IconAlertTriangle, } from "react-icons/hi2"; -import { FiCalendar, FiExternalLink } from "react-icons/fi"; +import { + FiArrowDown, + FiArrowUp, + FiCalendar, + FiExternalLink, +} from "react-icons/fi"; import { LiaDocker } from "react-icons/lia"; -import { Link, useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { TbCheck, TbCopy } from "react-icons/tb"; import { useClipboard } from "@mantine/hooks"; import { useAuth } from "@/utils/AuthContext"; import { MenuContext } from "@/components/Layouts/DashboardLayout"; -import { FaArrowDown, FaArrowUp } from "react-icons/fa"; -import { CUSTOM_DOMAIN_IP } from "@/config"; import { GoPlus } from "react-icons/go"; -import { HiRefresh } from "react-icons/hi"; import { FaPencil } from "react-icons/fa6"; +import { CUSTOM_DOMAIN_IP } from "@/config"; const AppSettingsPage = () => { const { app_id } = useParams(); @@ -707,6 +711,19 @@ const DomainsTab = ({ app: any; setRefresh: React.Dispatch>; }) => { + const DOMAIN_REGEX = + /^[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/; + + type AppDomain = { + id: string; + app_id?: string; + domain: string; + is_active?: boolean; + is_generated?: boolean; + date_created?: string; + type?: "custom" | "default" | "internal"; + }; + interface DnsRecord { type: string; name: string; @@ -755,460 +772,563 @@ const DomainsTab = ({ }, ]; - const [newDomain, setNewDomain] = useState(""); - const [isAddingDomain, setIsAddingDomain] = useState(false); - const [isDnsInstructionsOpen, setIsDnsInstructionsOpen] = useState(false); - const [editOpen, setEditOpen] = useState(false); - const [domainValue, setDomainValue] = useState(app?.url || ""); - const [editingType, setEditingType] = useState< - "custom" | "default" | "internal" | null - >(null); - const [domains, setDomains] = useState([ - { - type: "default", - value: app?.url, - status: app?.status || "active", - }, - { - type: "internal", - value: app?.internal_url, - status: app?.status || "active", - }, - ]); + const sortDomainsByPriority = (domainList: AppDomain[]) => { + return [...domainList].sort((a, b) => { + // First, sort by active status (active domains first) + if (a.is_active && !b.is_active) { + return -1; + } + if (!a.is_active && b.is_active) { + return 1; + } + + // Among active or inactive domains, prioritize by type + const typeOrder = { default: 0, custom: 1, internal: 2 }; + const aTypeOrder = typeOrder[a.type || "custom"]; + const bTypeOrder = typeOrder[b.type || "custom"]; + + if (aTypeOrder !== bTypeOrder) { + return aTypeOrder - bTypeOrder; + } + + // For same type, sort by date (newer first) + if (a.date_created && b.date_created) { + return ( + new Date(b.date_created).getTime() - + new Date(a.date_created).getTime() + ); + } + + return 0; + }); + }; + + const [domains, setDomains] = useState(() => { + const now = new Date().toISOString(); + const base: AppDomain[] = []; + + if (app?.url) { + base.push({ + id: `default-${app?.id || "local"}`, + app_id: app?.id, + domain: (app?.url || "").replace(/^https?:\/\//, ""), + is_active: !app?.has_custom_domain, + is_generated: false, + date_created: now, + type: "default", + }); + } + + base.push( + { + id: `custom-1-${Date.now()}`, + app_id: app?.id, + domain: "rhodin.xyz", + is_active: false, + is_generated: false, + date_created: now, + type: "custom", + }, + { + id: `custom-2-${Date.now() + 1}`, + app_id: app?.id, + domain: "www.rhodin.xyz", + is_active: false, + is_generated: false, + date_created: now, + type: "custom", + }, + ); + + return sortDomainsByPriority(base); + }); + + const [isAddEditOpen, setIsAddEditOpen] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [isAdding, setIsAdding] = useState(false); + const [editingDomain, setEditingDomain] = useState(null); + const [domainInput, setDomainInput] = useState(""); + const [openDnsInstructions, setOpenDnsInstructions] = useState>( + new Set(), + ); + // const [confirmDeleteId, setConfirmDeleteId] = useState(null); const { uploadData: addCustomDomain, submitting: addingCustomDomain, success: addedCustomDomainSuccess, - error: addCustomDomainError, - } = usePost(); - const { - uploadData: revertCustomDomain, - submitting: revertingCustomDomain, - success: revertedCustomDomainSuccess, - error: revertCustomDomainError, + // error: addCustomDomainError, } = usePost(); useEffect(() => { if (addedCustomDomainSuccess) { setRefresh((prev) => prev + 1); - setNewDomain(""); - setIsAddingDomain(false); - setEditOpen(false); - setEditingType(null); + setIsAddEditOpen(false); + setEditingDomain(null); + setDomainInput(""); } }, [addedCustomDomainSuccess]); useEffect(() => { - if (revertedCustomDomainSuccess) { - setEditOpen(false); - setEditingType(null); - setRefresh((prev) => prev + 1); - } - }, [revertedCustomDomainSuccess]); + setDomains((prev) => { + const custom = prev.filter((d) => d.type === "custom"); + const now = new Date().toISOString(); + const res: AppDomain[] = []; - const handleEdit = (type: "custom" | "default", value: string) => { - const cleanValue = value?.replace(/^https?:\/\//, ""); - setEditingType(type); - setDomainValue(cleanValue); - setEditOpen(true); - }; + if (app?.url) { + res.push({ + id: `default-${app?.id || "local"}`, + app_id: app?.id, + domain: (app?.url || "").replace(/^https?:\/\//, ""), + is_active: !app?.has_custom_domain, // Keep default active only if no custom domain + is_generated: false, + date_created: now, + type: "default", + }); + } - const handleCancel = () => { - setEditOpen(false); - setEditingType(null); - setDomainValue(""); - }; + const combined = [...res, ...custom]; + return sortDomainsByPriority(combined); + }); + }, [app?.url, app?.has_custom_domain, app?.id]); - const handleSave = () => { - if (!app?.id || !domainValue.trim()) { - return; - } + const validateDomain = (value: string) => DOMAIN_REGEX.test(value.trim()); - const cleanUrl = domainValue.trim().replace(/^https?:\/\//, ""); - addCustomDomain({ - api: "apps", - id: app?.id, - method: "PATCH", - params: { - custom_domain: cleanUrl, - }, - }); + const openAddModal = () => { + setEditingDomain(null); + setDomainInput(""); + setIsAdding(true); + setIsAddEditOpen(true); }; - const handleAddDomain = async () => { - if (!app?.id || !newDomain.trim()) { + const openEditModal = (d: AppDomain) => { + setEditingDomain(d); + setDomainInput(d.domain); + setIsAdding(false); + setIsAddEditOpen(true); + }; + + const handleAddOrSave = () => { + const clean = domainInput.trim().replace(/^https?:\/\//, ""); + if (!validateDomain(clean)) { return; } - setDomains((prev) => [ - { + if (editingDomain) { + // Update existing domain + setDomains((prev) => { + const updated = prev.map((p) => + p.id === editingDomain.id ? { ...p, domain: clean } : p, + ); + return sortDomainsByPriority(updated); + }); + + addCustomDomain({ + api: "apps", + id: app?.id, + method: "PATCH", + params: { + custom_domain: clean, + domain_id: editingDomain.id, + }, + }); + } else { + // Add new domain + const newDomain: AppDomain = { + id: `custom-${Date.now()}`, + app_id: app?.id, + domain: clean, + is_active: false, + is_generated: false, + date_created: new Date().toISOString(), type: "custom", - value: newDomain, - status: "pending", - }, - ...prev, - ]); + }; - addCustomDomain({ - api: "apps", - id: app?.id, - method: "PATCH", - params: { - custom_domain: newDomain.trim(), - }, - }); - }; + setDomains((prev) => sortDomainsByPriority([newDomain, ...prev])); - const handleRevertDomain = async () => { - if (!app?.id) { - return; + addCustomDomain({ + api: "apps", + id: app?.id, + method: "PATCH", + params: { + custom_domain: clean, + }, + }); } - revertCustomDomain({ - api: `apps/${app?.id}/revert_url`, - method: "PATCH", - }); + setIsAddEditOpen(false); + setEditingDomain(null); + setDomainInput(""); }; - const renderDnsInstructions = () => ( - - - - - - - - } - color="blue" - variant="light" - radius="md" - mb="md" - > - - DNS Configuration Required - - - Configure your DNS provider with the following settings to connect - your custom domain - - + const handleDelete = (id: string) => { + setDomains((prev) => { + const filtered = prev.filter((d) => d.id !== id); - - - Required DNS Records - Step-by-Step Guide - + // If we deleted the active custom domain, make default active + const deletedDomain = prev.find((d) => d.id === id); + if (deletedDomain?.is_active) { + const updated = filtered.map((d) => ({ + ...d, + is_active: d.type === "default", + })); + return sortDomainsByPriority(updated); + } - - - Add these DNS records to your domain provider to connect your - custom domain. - + return sortDomainsByPriority(filtered); + }); - - - - Type - Name - Value - TTL - Action - - - - {dnsRecords.map((record, index) => ( - - - - {record.type} - - - - {record.name} - - - {record.value} - - - {record.ttl} - - - - {({ copied, copy }) => ( - - - {copied ? ( - - ) : ( - - )} - - - )} - - - - ))} - - - - - - - Follow these steps in your DNS provider dashboard: - + // TODO: call backend delete endpoint when available + }; - - {dnsInstructions.map((instruction, index) => ( - - - {instruction.title}: - {instruction.value} - - {instruction.description} - - - - ))} - + const toggleDnsInstructions = (domainId: string) => { + setOpenDnsInstructions((prev) => { + const newSet = new Set(prev); + if (newSet.has(domainId)) { + newSet.delete(domainId); + } else { + newSet.add(domainId); + } + return newSet; + }); + }; - - - After configuring your DNS records, it may take up to 24 hours - for changes to propagate. You can verify your domain - configuration once the DNS changes are active. - - - - - - - - ); + const renderDomainRow = (d: AppDomain) => { + const activeBadge = d.is_active ? ( + }> + Current + + ) : null; + + const generatedBadge = d.is_generated ? ( + + Generated + + ) : null; - const renderDomainRow = ( - value: string, - badge?: string, - type?: "custom" | "default" | "internal", - ) => ( - - - - - {type !== "internal" ? ( - { + switch (d.type) { + case "default": + return ( + + Default + + ); + case "custom": + return ( + + Custom + + ); + case "internal": + return ( + + Internal + + ); + default: + return null; + } + })(); + + const isDnsOpen = openDnsInstructions.has(d.id); + + return ( + + + {/* Left side */} + + + - {value} - {badge === "Current" && } - - ) : ( - {value} - )} - {badge === "Current" && ( - }> - {badge} - - )} - - - - - {type !== "internal" && - !(type === "default" && value?.includes("cranecloud.io")) && ( - + {d.domain} + + + {d.is_active && d.type !== "internal" && ( + + + )} - - - - - - setDomainValue(e.currentTarget.value)} - autoFocus - error={ - revertCustomDomainError?.data?.message || - addCustomDomainError?.data?.message - } - /> - - - {badge === "Current" && ( + + + + {activeBadge} + {generatedBadge} + {typeBadge} + {d.type !== "default" && ( )} - - - - + + + )} + + {d.type === "custom" && ( + handleDelete(d.id)} + title="Delete domain" + > + + + )} - - - - ); + - useEffect(() => { - if (app?.url || app?.internal_url) { - setDomains([ - { - type: "default", - value: app?.url, - status: app?.status || "active", - }, - { - type: "internal", - value: app?.internal_url, - status: app?.status || "active", - }, - ]); - } - }, [app?.url, app?.internal_url, app?.status]); + {d.type !== "default" && ( + + + } + color="blue" + variant="light" + radius="md" + mb="md" + > + + DNS Configuration Required + + + Configure your DNS provider with the following settings to + connect your custom domain + + + + + + Required DNS Records + Step-by-Step Guide + + + + + Add these DNS records to your domain provider to connect + your custom domain. + + + + + + Type + Name + Value + TTL + Action + + + + {dnsRecords.map((record, index) => ( + + + + {record.type} + + + + {record.name} + + + {record.value} + + + {record.ttl} + + + + {({ copied, copy }) => ( + + + {copied ? ( + + ) : ( + + )} + + + )} + + + + ))} + + + + + + + Follow these steps in your DNS provider dashboard: + + + + {dnsInstructions.map((instruction, index) => ( + + + {instruction.title}: + {instruction.value} + + {instruction.description} + + + + ))} + + + + + After configuring your DNS records, it may take up to 24 + hours for changes to propagate. You can verify your domain + configuration once the DNS changes are active. + + + + + + + )} + + ); + }; return (
-
- - - - - - } - > - Domains - - - setIsAddingDomain(false)} - title="Add Custom Domain" - size="lg" - > - - } - color="blue" - variant="light" - radius="md" - > - - Accepted Domain Formats - - - - example.com - Root domain - - - www.example.com - Subdomain with www - - - app.example.com - Custom subdomain - - - my-app.example.com - Subdomain with hyphens - - - - Note: Do not include http:// or https:// in your domain name - - - - setNewDomain(e.currentTarget.value)} - error={addCustomDomainError?.data?.message} - /> - - - - - - - -
+ + + + } + > + Domains + - {domains.map((domain, idx) => ( - - {renderDomainRow( - domain.value, - domain.type === "default" ? "Current" : "", - domain.type as "custom" | "default" | "internal", - )} - {idx < domains.length - 1 && } - - ))} + + {domains.length === 0 ? ( + No domains configured yet. + ) : ( + domains.map((d, i) => ( + + {renderDomainRow(d)} + + {i < domains.length - 1 && } + + )) + )} + - {renderDnsInstructions()} + setIsAddEditOpen(false)} + title={editingDomain ? "Edit Domain" : "Add Custom Domain"} + size="lg" + > + + } + color="blue" + variant="light" + radius="md" + > + + Accepted Domain Formats + + + Do not include http:// or https:// in your domain name + + + + setDomainInput(e.currentTarget.value)} + error={ + !domainInput + ? undefined + : !validateDomain(domainInput) + ? "Invalid domain format" + : undefined + } + autoFocus + /> + + + + + + + + + {/* {renderDnsInstructions()} */}
); };