diff --git a/src/app/instances/[name]/alarms/components/CreateAlarmButton.tsx b/src/app/instances/[name]/alarms/components/CreateAlarmButton.tsx new file mode 100644 index 0000000..575e690 --- /dev/null +++ b/src/app/instances/[name]/alarms/components/CreateAlarmButton.tsx @@ -0,0 +1,15 @@ +export default function CreateAlarmButton({ onClick }: { onClick: () => void }) { + return ( +
+ +
+ ) +} diff --git a/src/app/components/Dropdown.tsx b/src/app/instances/[name]/alarms/components/Dropdown.tsx similarity index 100% rename from src/app/components/Dropdown.tsx rename to src/app/instances/[name]/alarms/components/Dropdown.tsx diff --git a/src/app/instances/[name]/alarms/components/LoadingSkeleton.tsx b/src/app/instances/[name]/alarms/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..8401552 --- /dev/null +++ b/src/app/instances/[name]/alarms/components/LoadingSkeleton.tsx @@ -0,0 +1,14 @@ +export default function LoadingSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/src/app/instances/[name]/alarms/components/MemoryAlarmsSection.tsx b/src/app/instances/[name]/alarms/components/MemoryAlarmsSection.tsx new file mode 100644 index 0000000..d98c0a8 --- /dev/null +++ b/src/app/instances/[name]/alarms/components/MemoryAlarmsSection.tsx @@ -0,0 +1,68 @@ +import Dropdown from "./Dropdown" +import Alarm from "../types/alarm"; + +interface Props { + memoryAlarms: Alarm[]; + onDelete: (type: "storage" | "memory", id: string) => void; + onTrigger: (type: "storage" | "memory", alarm: Alarm) => void; +} + +export default function MemoryAlarmsSection({ + memoryAlarms, + onDelete, + onTrigger, +}: Props) { + return ( +
+

+ Memory Alarms +

+
+ + + + + + + + + + {memoryAlarms.length === 0 ? ( + + + + ) : ( + memoryAlarms.map((alarm) => ( + + + + + + )) + )} + +
+ Reminder Interval + + Memory Threshold + + Actions +
+ No memory alarms yet. +
+ {alarm.data.reminderInterval} + + {alarm.data.memoryThreshold} + + onDelete("memory", alarm.id), + Trigger: () => onTrigger("memory", alarm), + }} + /> +
+
+
+ ) +} diff --git a/src/app/components/NewAlarmModal.tsx b/src/app/instances/[name]/alarms/components/NewAlarmModal.tsx similarity index 98% rename from src/app/components/NewAlarmModal.tsx rename to src/app/instances/[name]/alarms/components/NewAlarmModal.tsx index e1108c9..5d9717a 100644 --- a/src/app/components/NewAlarmModal.tsx +++ b/src/app/instances/[name]/alarms/components/NewAlarmModal.tsx @@ -1,6 +1,6 @@ -import { useInstanceContext } from "../instances/[name]/InstanceContext"; +import { useInstanceContext } from "../../InstanceContext"; import { useState } from "react"; -import ErrorBanner from "@/app/components/ErrorBanner"; +import ErrorBanner from "@/app/instances/components/ErrorBanner"; import axios from "axios"; interface Alarm { diff --git a/src/app/instances/[name]/alarms/components/SlackEndpointCard.tsx b/src/app/instances/[name]/alarms/components/SlackEndpointCard.tsx new file mode 100644 index 0000000..c97bc86 --- /dev/null +++ b/src/app/instances/[name]/alarms/components/SlackEndpointCard.tsx @@ -0,0 +1,45 @@ +interface Props { + webhookUrl: string; + onClick: () => void; +} + +export default function SlackEndpointCard({ + webhookUrl, + onClick, +}: Props) { + return ( +
+

Slack Endpoint

+ {webhookUrl ? ( + + + + + + + + + + + +
Webhook URL:{webhookUrl}
+ ) : ( +

+ You must set up a slack endpoint before creating alarms. +

+ )} +
+ +
+
+ + ) +} diff --git a/src/app/components/SlackModal.tsx b/src/app/instances/[name]/alarms/components/SlackModal.tsx similarity index 97% rename from src/app/components/SlackModal.tsx rename to src/app/instances/[name]/alarms/components/SlackModal.tsx index bc41260..167a089 100644 --- a/src/app/components/SlackModal.tsx +++ b/src/app/instances/[name]/alarms/components/SlackModal.tsx @@ -1,7 +1,7 @@ -import { useInstanceContext } from "../instances/[name]/InstanceContext"; +import { useInstanceContext } from "../../InstanceContext"; import { useState } from "react"; import axios from "axios"; -import ErrorBanner from "@/app/components/ErrorBanner"; +import ErrorBanner from "@/app/instances/components/ErrorBanner"; interface Props { url: string; diff --git a/src/app/instances/[name]/alarms/components/StorageAlarmsSection.tsx b/src/app/instances/[name]/alarms/components/StorageAlarmsSection.tsx new file mode 100644 index 0000000..d978db9 --- /dev/null +++ b/src/app/instances/[name]/alarms/components/StorageAlarmsSection.tsx @@ -0,0 +1,68 @@ +import Dropdown from "./Dropdown" +import Alarm from "../types/alarm"; + +interface Props { + storageAlarms: Alarm[]; + onDelete: (type: "storage" | "memory", id: string) => void; + onTrigger: (type: "storage" | "memory", alarm: Alarm) => void; +} + +export default function StorageAlarmsSection({ + storageAlarms, + onDelete, + onTrigger, +}: Props) { + return ( +
+

+ Storage Alarms +

+
+ + + + + + + + + + {storageAlarms.length === 0 ? ( + + + + ) : ( + storageAlarms.map((alarm) => ( + + + + + + )) + )} + +
+ Storage Threshold + + Reminder Interval + + Actions +
+ No storage alarms yet. +
+ {alarm.data.storageThreshold} + + {alarm.data.reminderInterval} + + onDelete("storage", alarm.id), + Trigger: () => onTrigger("storage", alarm), + }} + /> +
+
+
+ ) +} diff --git a/src/app/instances/[name]/alarms/page.tsx b/src/app/instances/[name]/alarms/page.tsx index 01698e2..ff59f5b 100644 --- a/src/app/instances/[name]/alarms/page.tsx +++ b/src/app/instances/[name]/alarms/page.tsx @@ -1,20 +1,17 @@ "use client"; +import axios from "axios"; import { useInstanceContext } from "../InstanceContext"; import { useEffect, useState } from "react"; -import { NewAlarmModal } from "@/app/components/NewAlarmModal"; -import { SlackModal } from "@/app/components/SlackModal"; -import Dropdown from "@/app/components/Dropdown"; -import axios from "axios"; +import { NewAlarmModal } from "./components/NewAlarmModal"; +import { SlackModal } from "./components/SlackModal"; +import SlackEndpointCard from "./components/SlackEndpointCard"; +import StorageAlarmsSection from "./components/StorageAlarmsSection"; +import MemoryAlarmsSection from "./components/MemoryAlarmsSection"; +import LoadingSkeleton from "./components/LoadingSkeleton"; +import CreateAlarmButton from "./components/CreateAlarmButton"; +import Alarm from "./types/alarm"; -interface Alarm { - id: string; - data: { - memoryThreshold: number; - storageThreshold: number; - reminderInterval: number; - }; -} export default function AlarmsPage() { const { instance } = useInstanceContext(); @@ -73,8 +70,11 @@ export default function AlarmsPage() { await axios.delete( `/api/instances/${instance?.name}/alarms?region=${instance?.region}&type=${type}&id=${id}` ); - setStorageAlarms((prev) => prev.filter((alarm) => alarm.id !== id)); - setMemoryAlarms((prev) => prev.filter((alarm) => alarm.id !== id)); + if (type === "storage") { + setStorageAlarms((prev) => prev.filter((alarm) => alarm.id !== id)); + } else { + setMemoryAlarms((prev) => prev.filter((alarm) => alarm.id !== id)); + } } catch (error) { console.error("Error deleting alarm:", error); } @@ -108,41 +108,10 @@ export default function AlarmsPage() { return ( <> - {/* Slack Endpoint Card */} -
-

- Slack Endpoint -

- {webhookUrl ? ( - - - - - - - - - - - -
Webhook URL:{webhookUrl}
- ) : ( -

- You must set up a slack endpoint before creating alarms. -

- )} -
- -
-
+ setShowSlackModal(true)} + /> {webhookUrl && ( <> @@ -156,141 +125,22 @@ export default function AlarmsPage() { alarms to receive notifications via Slack using the button below.

{isLoading ? ( -
-
-
-
-
-
-
-
-
-
+ ) : ( <> - {/* Storage Alarms Section */} -
-

- Storage Alarms -

-
- - - - - - - - - - {storageAlarms.length === 0 ? ( - - - - ) : ( - storageAlarms.map((alarm) => ( - - - - - - )) - )} - -
- Storage Threshold - - Reminder Interval - Actions
- No storage alarms yet. -
- {alarm.data.storageThreshold} - - {alarm.data.reminderInterval} - - - handleDelete("storage", alarm.id), - Trigger: () => - handleTrigger("storage", alarm), - }} - /> -
-
-
- - {/* Memory Alarms Section */} -
-

- Memory Alarms -

-
- - - - - - - - - - {memoryAlarms.length === 0 ? ( - - - - ) : ( - memoryAlarms.map((alarm) => ( - - - - - - )) - )} - -
- Reminder Interval - - Memory Threshold - Actions
- No memory alarms yet. -
- {alarm.data.reminderInterval} - - {alarm.data.memoryThreshold} - - - handleDelete("memory", alarm.id), - Trigger: () => - handleTrigger("memory", alarm), - }} - /> -
-
-
- -
- -
+ + + + + setShowNewAlarmModal(true)} /> )} diff --git a/src/app/instances/[name]/alarms/types/alarm.ts b/src/app/instances/[name]/alarms/types/alarm.ts new file mode 100644 index 0000000..a1cc8a7 --- /dev/null +++ b/src/app/instances/[name]/alarms/types/alarm.ts @@ -0,0 +1,8 @@ +export default interface Alarm { + id: string; + data: { + memoryThreshold: number; + storageThreshold: number; + reminderInterval: number; + }; +} diff --git a/src/app/instances/[name]/backups/components/BackupsDescription.tsx b/src/app/instances/[name]/backups/components/BackupsDescription.tsx new file mode 100644 index 0000000..5518319 --- /dev/null +++ b/src/app/instances/[name]/backups/components/BackupsDescription.tsx @@ -0,0 +1,26 @@ +export default function BackupsDescription() { + return ( + <> +

Backups

+

+ A “definition” in RabbitMQ is a snapshot of your server’s configuration + — including exchanges, queues, users, and permissions. We refer to these + as “backups”. See{" "} + + RabbitMQ documentation + {" "} + for more details. +

+

+ Below, you can manually create and download backups for safekeeping or + migration. All backups are also securely stored in the cloud, so they’ll + be available anytime you return to this page. +

+ + ) +} diff --git a/src/app/instances/[name]/backups/components/BackupsTable.tsx b/src/app/instances/[name]/backups/components/BackupsTable.tsx new file mode 100644 index 0000000..272dd99 --- /dev/null +++ b/src/app/instances/[name]/backups/components/BackupsTable.tsx @@ -0,0 +1,81 @@ +import Backup from "../types/backup"; +import { Instance } from "@/types/instance"; + +interface Props { + backups: Backup[]; + instance: Instance; +} + +export default function BackupsTable({ + backups, + instance, +}: Props) { + const handleDownload = (backup: Backup) => { + const jsonString = JSON.stringify(backup.definitions, null, 2); + + const blob = new Blob([jsonString], { type: "application/json" }); + + const url = window.URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `${instance?.name}-${backup.timestamp}.json`); + document.body.appendChild(link); + link.click(); + + window.URL.revokeObjectURL(url); + link.remove(); + }; + + return ( +
+ + + + + + + + + + + {backups.length === 0 ? ( + + + + ) : ( + backups.map((backup, idx) => ( + + + + + + + )) + )} + +
+ Date Created + + RabbitMQ Version + + Trigger +
+ No backups found. +
+ {backup.timestamp} + + {backup.rabbitmq_version} + + {backup.trigger} + + +
+
+ ) +} diff --git a/src/app/instances/[name]/backups/components/LoadingSkeleton.tsx b/src/app/instances/[name]/backups/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..643e8bb --- /dev/null +++ b/src/app/instances/[name]/backups/components/LoadingSkeleton.tsx @@ -0,0 +1,42 @@ +export default function LoadingSkeleton() { + return ( +
+
+ + + + + + + + + + + {[...Array(3)].map((_, index) => ( + + + + + + + ))} + +
+ Date Created + + RabbitMQ Version + + Trigger +
+
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/src/app/instances/[name]/backups/components/ManualBackupButton.tsx b/src/app/instances/[name]/backups/components/ManualBackupButton.tsx new file mode 100644 index 0000000..ce57757 --- /dev/null +++ b/src/app/instances/[name]/backups/components/ManualBackupButton.tsx @@ -0,0 +1,30 @@ +import SubmissionSpinner from "@/app/instances/components/SubmissionSpinner"; + +interface Props { + onClick: () => void; + disabled: boolean; +} + +export default function ManualBackupButton({ + onClick, + disabled, +}: Props) { + return ( +
+ +
+ ) +} diff --git a/src/app/instances/[name]/backups/page.tsx b/src/app/instances/[name]/backups/page.tsx index 033446f..2538d70 100644 --- a/src/app/instances/[name]/backups/page.tsx +++ b/src/app/instances/[name]/backups/page.tsx @@ -2,19 +2,20 @@ import React, { useEffect, useState } from "react"; import axios from "axios"; import { useInstanceContext } from "../InstanceContext"; -import SubmissionSpinner from "@/app/components/SubmissionSpinner"; import { useNotificationsContext } from "@/app/NotificationContext"; - -interface Backup { - timestamp: string; - rabbitmq_version: string; - trigger: string; - definitions: Record; -} +import BackupsDescription from "./components/BackupsDescription"; +import ManualBackupButton from "./components/ManualBackupButton"; +import LoadingSkeleton from "./components/LoadingSkeleton"; +import BackupsTable from "./components/BackupsTable"; +import Backup from "./types/backup"; export default function BackupsPage() { const { instance } = useInstanceContext(); + if (!instance) { + throw new Error("Instance not found"); + } + const { addNotification, formPending } = useNotificationsContext(); const [backups, setBackups] = useState([]); @@ -42,7 +43,7 @@ export default function BackupsPage() { const handleManualBackup = async () => { if (!instance?.name) return; - await addNotification({ + addNotification({ type: "backup", status: "pending", instanceName: instance?.name, @@ -66,151 +67,21 @@ export default function BackupsPage() { } }; - const handleDownload = (backup: Backup) => { - const jsonString = JSON.stringify(backup.definitions, null, 2); - - const blob = new Blob([jsonString], { type: "application/json" }); - - const url = window.URL.createObjectURL(blob); - - const link = document.createElement("a"); - link.href = url; - link.setAttribute("download", `${instance?.name}-${backup.timestamp}.json`); - document.body.appendChild(link); - link.click(); - - window.URL.revokeObjectURL(url); - link.remove(); - }; - return (
-

Backups

-

- A “definition” in RabbitMQ is a snapshot of your server’s configuration - — including exchanges, queues, users, and permissions. We refer to these - as “backups”. See{" "} - - RabbitMQ documentation - {" "} - for more details. -

-

- Below, you can manually create and download backups for safekeeping or - migration. All backups are also securely stored in the cloud, so they’ll - be available anytime you return to this page. -

-
- -
+ + {isLoading ? ( -
-
- - - - - - - - - - - {[...Array(3)].map((_, index) => ( - - - - - - - ))} - -
- Date Created - - RabbitMQ Version - - Trigger -
-
-
-
-
-
-
-
-
-
-
+ ) : ( -
- - - - - - - - - - - {backups.length === 0 ? ( - - - - ) : ( - backups.map((backup, idx) => ( - - - - - - - )) - )} - -
- Date Created - - RabbitMQ Version - - Trigger -
- No backups found. -
- {backup.timestamp} - - {backup.rabbitmq_version} - - {backup.trigger} - - -
-
+ )}
); diff --git a/src/app/instances/[name]/backups/types/backup.ts b/src/app/instances/[name]/backups/types/backup.ts new file mode 100644 index 0000000..4adfe10 --- /dev/null +++ b/src/app/instances/[name]/backups/types/backup.ts @@ -0,0 +1,7 @@ +export default interface Backup { + timestamp: string; + rabbitmq_version: string; + trigger: string; + definitions: Record; +} + diff --git a/src/app/instances/[name]/components/InstanceInfo.tsx b/src/app/instances/[name]/components/InstanceInfo.tsx new file mode 100644 index 0000000..bdb9be0 --- /dev/null +++ b/src/app/instances/[name]/components/InstanceInfo.tsx @@ -0,0 +1,52 @@ +import formatDate from "@/utils/formatDate"; +import { Instance } from "@/types/instance"; + +export default function InstanceInfo({ + instance }: { instance: Instance }) { + return ( +
+

+ Instance Info +

+ + + + + + + + + + + + + + + + + + + + + + + +
Status: + {instance?.state} +
Host:{instance?.publicDns}
Created at: + {instance?.launchTime && formatDate(instance?.launchTime)} +
Data Center:{instance?.region}
+
+ ) +} diff --git a/src/app/instances/[name]/components/ServerInfo.tsx b/src/app/instances/[name]/components/ServerInfo.tsx new file mode 100644 index 0000000..c87fbf7 --- /dev/null +++ b/src/app/instances/[name]/components/ServerInfo.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { Copy, Eye, EyeOff } from "lucide-react"; +import { Instance } from "@/types/instance"; + +export default function ServerInfo({ instance }: { instance: Instance }) { + const [showCopied, setShowCopied] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showUrlPassword, setShowUrlPassword] = useState(false); + const handleCopy = async () => { + if (!instance?.endpointUrl) return; + await navigator.clipboard.writeText(instance.endpointUrl); + setShowCopied(true); + setTimeout(() => setShowCopied(false), 2000); + }; + const togglePassword = () => { + setShowPassword((prev) => !prev); + }; + + const toggleUrlPassword = () => { + setShowUrlPassword((prev) => !prev); + }; + + + const secureWindow = () => { + return ( + window !== undefined && + (new RegExp(/^https/).test(window.location.href) || + new RegExp(/^http:\/\/localhost/).test(window.location.href)) + ); + }; + const getDisplayedEndpointUrl = (): string => { + if (!instance || !instance.endpointUrl) return ""; + + try { + const full = instance.endpointUrl; + + const protocolSplit = full.split("://"); + if (protocolSplit.length !== 2) return full; + + const protocol = protocolSplit[0] + "://"; + const rest = protocolSplit[1]; + + const atIndex = rest.lastIndexOf("@"); + if (atIndex === -1) return full; + + const credentials = rest.slice(0, atIndex); + const hostAndPath = rest.slice(atIndex + 1); + + const [username, rawPassword] = credentials.split(":"); + + if (!username || !rawPassword) return full; + + const displayedPassword = showUrlPassword + ? decodeURIComponent(rawPassword) + : "•".repeat(decodeURIComponent(rawPassword).length || 8); + + return `${protocol}${username}:${displayedPassword}@${hostAndPath}`; + } catch (error) { + console.error("Error parsing endpoint URL:", error); + return instance.endpointUrl; + } + }; + + + return ( +
+

+ RabbitMQ Server Info: +

+ + + + + + + + + + + + + + + + + + + + + + + +
Port:{instance?.port}
User:{instance?.user}
Password: + + {showPassword + ? instance?.password + : "•".repeat(instance?.password?.length || 8)} + + +
RabbitMQ Connection URL: +
+ {getDisplayedEndpointUrl()} + +
+ {secureWindow() && ( + + )} + {showCopied && ( +
+ RabbitMQ URL copied to clipboard +
+ )} +
+
+
+
+ ) +} diff --git a/src/app/instances/[name]/configuration/components/ComponentsForm.tsx b/src/app/instances/[name]/configuration/components/ComponentsForm.tsx new file mode 100644 index 0000000..2e4629c --- /dev/null +++ b/src/app/instances/[name]/configuration/components/ComponentsForm.tsx @@ -0,0 +1,76 @@ +import { configItems } from "@/types/configuration"; +import Link from "next/link"; +import SubmissionSpinner from "@/app/instances/components/SubmissionSpinner"; +import ComponentsFormRow from "./ComponentsFormRow"; + +interface Props { + configuration: Record; + isLoading: boolean; + onChange: (e: React.ChangeEvent) => void; + onSubmit: (e: React.FormEvent) => void; + disabled: boolean; + pending: boolean; +} + +export default function ComponentsForm({ + configuration, + isLoading, + onChange, + onSubmit, + disabled, +}: Props) { + return ( +
+ + + + + + + + + + {configItems.map((item) => ( + + ))} + +
SettingDescriptionValue
+ +
+ + Cancel + + +
+
+ ) +} diff --git a/src/app/instances/[name]/configuration/components/ComponentsFormRow.tsx b/src/app/instances/[name]/configuration/components/ComponentsFormRow.tsx new file mode 100644 index 0000000..5fe8df1 --- /dev/null +++ b/src/app/instances/[name]/configuration/components/ComponentsFormRow.tsx @@ -0,0 +1,62 @@ +import { ConfigItem } from "@/types/configuration"; + +interface Props { + item: ConfigItem; + configuration: Record; + isLoading: boolean; + onChange: (e: React.ChangeEvent) => void; +} + +export default function ComponentsFormRow({ + item, + configuration, + isLoading, + onChange, +}: Props) { + return ( + + + {isLoading ? ( +
+ ) : ( + item.key + )} + + + {isLoading ? ( +
+ ) : ( + item.description + )} + + + {isLoading ? ( +
+ ) : item.type === "dropdown" && Array.isArray(item.options) ? ( + + ) : ( + + )} + + + ) +} diff --git a/src/app/instances/[name]/configuration/components/ConfigurationDescription.tsx b/src/app/instances/[name]/configuration/components/ConfigurationDescription.tsx new file mode 100644 index 0000000..e915867 --- /dev/null +++ b/src/app/instances/[name]/configuration/components/ConfigurationDescription.tsx @@ -0,0 +1,22 @@ +export default function ConfigurationDescription() { + return ( + <> +

+ Configuration +

+

+ Below are the RabbitMQ server configurations. For detailed explanations + of each setting, refer to the{" "} + + RabbitMQ Configuration Guide + + . +

+ + ) +} diff --git a/src/app/instances/[name]/configuration/page.tsx b/src/app/instances/[name]/configuration/page.tsx index f6cabea..3556ac9 100644 --- a/src/app/instances/[name]/configuration/page.tsx +++ b/src/app/instances/[name]/configuration/page.tsx @@ -4,11 +4,10 @@ import axios from "axios"; import { useEffect, useState } from "react"; import { useInstanceContext } from "../InstanceContext"; import { useNotificationsContext } from "@/app/NotificationContext"; -import { configItems } from "@/types/configuration"; import { validateConfiguration } from "@/utils/validateConfig"; -import ErrorBanner from "@/app/components/ErrorBanner"; -import Link from "next/link"; -import SubmissionSpinner from "@/app/components/SubmissionSpinner"; +import ErrorBanner from "@/app/instances/components/ErrorBanner"; +import ConfigurationDescription from "./components/ConfigurationDescription"; +import ComponentsForm from "./components/ComponentsForm"; interface Configuration { [key: string]: string; @@ -69,7 +68,7 @@ export default function ConfigurationPage() { } try { - await addNotification({ + addNotification({ type: "configuration", status: "pending", instanceName: instance.name, @@ -96,22 +95,7 @@ export default function ConfigurationPage() { className="max-w-4xl mx-auto p-6 bg-card text-pagetext1 rounded-sm shadow-md mt-8" ref={configSectionRef} > -

- Configuration -

-

- Below are the RabbitMQ server configurations. For detailed explanations - of each setting, refer to the{" "} - - RabbitMQ Configuration Guide - - . -

+ {errors.length > 0 && (
@@ -125,96 +109,14 @@ export default function ConfigurationPage() {
)} -
- - - - - - - - - - {configItems.map((item) => ( - - - - - - ))} - -
SettingDescriptionValue
- {isLoading ? ( -
- ) : ( - item.key - )} -
- {isLoading ? ( -
- ) : ( - item.description - )} -
- {isLoading ? ( -
- ) : item.type === "dropdown" && item.options ? ( - - ) : ( - - )} -
- -
- - Cancel - - -
-
+ 0 || formPending()} + pending={formPending()} + /> ); } diff --git a/src/app/instances/[name]/configuration/types/configuration.ts b/src/app/instances/[name]/configuration/types/configuration.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/instances/[name]/firewall/components/FirewallDescription.tsx b/src/app/instances/[name]/firewall/components/FirewallDescription.tsx new file mode 100644 index 0000000..dbb4ee3 --- /dev/null +++ b/src/app/instances/[name]/firewall/components/FirewallDescription.tsx @@ -0,0 +1,33 @@ +export default function FirewallDescription() { + return ( + <> +

+ Firewall Settings +

+

+ Configuring firewall rules allows you to manage access to both your AWS + EC2 instance and the RabbitMQ server. Adjusting these settings updates + AWS security groups and configures the necessary plugins and ports on + the RabbitMQ server. +

+

+ The Common Ports section offers a list of protocols that can be enabled + with a click, while the Custom Ports section allows specifying + additional ports as a comma-separated list. Please note that only IPv4 + is supported for the Source IP at this time. +

+

+ For more details on supported protocols, refer to the{" "} + + RabbitMQ Supported Protocols + + . +

+ + ) +} diff --git a/src/app/instances/[name]/firewall/components/FirewallRuleCommonPorts.tsx b/src/app/instances/[name]/firewall/components/FirewallRuleCommonPorts.tsx new file mode 100644 index 0000000..aff211c --- /dev/null +++ b/src/app/instances/[name]/firewall/components/FirewallRuleCommonPorts.tsx @@ -0,0 +1,47 @@ +import { COMMON_PORTS } from "@/utils/firewallConstants"; +import { Info } from "lucide-react"; +import { FirewallRule } from "@/types/firewall"; + +interface Props { + rule: FirewallRule; + index: number; + onChange: (index: number, port: string) => void; +} + +export default function FirewallRuleCommonPorts({ + rule, + index, + onChange, +}: Props) { + return ( +
+ +
+ {COMMON_PORTS.map(({ name, port, desc }) => ( +
+ onChange(index, name)} + className="font-text1 bg-checkmark h-3 w-3" + /> + {name} + + {/* Tooltip Icon */} + + + {/* Tooltip Box */} +
+ Port {port}: {desc} +
+
+ ))} +
+
+ ) +} diff --git a/src/app/instances/[name]/firewall/components/FirewallRuleCustomPorts.tsx b/src/app/instances/[name]/firewall/components/FirewallRuleCustomPorts.tsx new file mode 100644 index 0000000..f229c33 --- /dev/null +++ b/src/app/instances/[name]/firewall/components/FirewallRuleCustomPorts.tsx @@ -0,0 +1,45 @@ +import { FirewallRule } from "@/types/firewall"; +import { Trash2 } from "lucide-react"; + +interface Props { + rule: FirewallRule; + index: number; + onChange: (index: number, value: string) => void; + removeRule: (index: number) => void; +} + +export default function FirewallRuleCustomPortds({ + rule, + index, + onChange, + removeRule, +}: Props) { + return ( +
+ +
+ + onChange(index, e.target.value) + } + className="font-text1 flex-grow h-9 text-xs p-2 border rounded" + /> + + {/* Drop Button */} +
+ +
+
+
+ ) +} diff --git a/src/app/instances/[name]/firewall/components/FirewallRuleDescription.tsx b/src/app/instances/[name]/firewall/components/FirewallRuleDescription.tsx new file mode 100644 index 0000000..90e6b5e --- /dev/null +++ b/src/app/instances/[name]/firewall/components/FirewallRuleDescription.tsx @@ -0,0 +1,29 @@ +import { FirewallRule } from "@/types/firewall"; + +interface Props { + rule: FirewallRule; + index: number; + onChange: (index: number, value: string) => void; +} + +export default function FirewallRuleDescription({ + rule, + index, + onChange, +}: Props) { + return ( +
+ + + onChange(index, e.target.value) + } + className="font-text1 w-full h-9 text-xs p-2 border rounded" + /> +
+ ) +} diff --git a/src/app/instances/[name]/firewall/components/FirewallRuleSourceIp.tsx b/src/app/instances/[name]/firewall/components/FirewallRuleSourceIp.tsx new file mode 100644 index 0000000..d94282b --- /dev/null +++ b/src/app/instances/[name]/firewall/components/FirewallRuleSourceIp.tsx @@ -0,0 +1,32 @@ +import { FirewallRule } from "@/types/firewall"; +interface Props { + rule: FirewallRule; + index: number; + onChange: (index: number, value: string) => void; + onBlur: (value: string) => void; +} + +export default function SourceIp({ + rule, + index, + onChange, + onBlur, +}: Props) { + return ( +
+ + + onChange(index, e.target.value) + } + onBlur={() => onBlur(rule.sourceIp)} + className="font-text1 w-full h-9 text-xs p-2 border rounded" + /> +
+ ) +} diff --git a/src/app/instances/[name]/firewall/components/LoadingSkeleton.tsx b/src/app/instances/[name]/firewall/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..919bcaa --- /dev/null +++ b/src/app/instances/[name]/firewall/components/LoadingSkeleton.tsx @@ -0,0 +1,19 @@ +export default function LoadingSkeleton() { + return ( +
+ {[...Array(3)].map((_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/src/app/instances/[name]/firewall/page.tsx b/src/app/instances/[name]/firewall/page.tsx index c33c1d7..1bc4069 100644 --- a/src/app/instances/[name]/firewall/page.tsx +++ b/src/app/instances/[name]/firewall/page.tsx @@ -5,18 +5,22 @@ import axios from "axios"; import React from "react"; import { useInstanceContext } from "../InstanceContext"; import { FirewallRule } from "@/types/firewall"; -import ErrorBanner from "@/app/components/ErrorBanner"; +import ErrorBanner from "@/app/instances/components/ErrorBanner"; import { isValidDescription, isValidSourceIp, isInRangeCustomPort, } from "@/utils/firewallValidation"; import { COMMON_PORTS } from "@/utils/firewallConstants"; -import { Info } from "lucide-react"; -import { Trash2 } from "lucide-react"; +import FirewallDescription from "./components/FirewallDescription"; +import LoadingSkeleton from "./components/LoadingSkeleton"; +import FirewallRuleDescription from "./components/FirewallRuleDescription"; +import FirewallRuleSourceIp from "./components/FirewallRuleSourceIp"; +import FirewallRuleCommonPorts from "./components/FirewallRuleCommonPorts"; +import FirewallRuleCustomPort from "./components/FirewallRuleCustomPorts"; import { useNotificationsContext } from "@/app/NotificationContext"; -import SubmissionSpinner from "@/app/components/SubmissionSpinner"; +import SubmissionSpinner from "../../components/SubmissionSpinner"; export default function FirewallPage() { const { instance } = useInstanceContext(); @@ -241,7 +245,7 @@ export default function FirewallPage() { } try { - await addNotification({ + addNotification({ type: "firewall", status: "pending", instanceName: instance.name, @@ -279,33 +283,7 @@ export default function FirewallPage() { className="max-w-4xl mx-auto p-6 bg-card rounded-md shadow-md mt-8 text-pagetext1" ref={configSectionRef} > -

- Firewall Settings -

-

- Configuring firewall rules allows you to manage access to both your AWS - EC2 instance and the RabbitMQ server. Adjusting these settings updates - AWS security groups and configures the necessary plugins and ports on - the RabbitMQ server. -

-

- The Common Ports section offers a list of protocols that can be enabled - with a click, while the Custom Ports section allows specifying - additional ports as a comma-separated list. Please note that only IPv4 - is supported for the Source IP at this time. -

-

- For more details on supported protocols, refer to the{" "} - - RabbitMQ Supported Protocols - - . -

+ {errors.length > 0 && (
@@ -322,21 +300,7 @@ export default function FirewallPage() {
e.preventDefault()}>
{isLoading ? ( -
- {[...Array(3)].map((_, index) => ( -
-
-
-
-
-
-
-
- ))} -
+ ) : ( rules.map((rule, index) => (
- {/* Description */} -
- - - handleDescriptionChange(index, e.target.value) - } - className="font-text1 w-full h-9 text-xs p-2 border rounded" - /> -
- - {/* Source IP */} -
- - - handleSourceIpChange(index, e.target.value) - } - onBlur={() => handleSourceIpBlur(rule.sourceIp)} - className="font-text1 w-full h-9 text-xs p-2 border rounded" - /> -
- - {/* Common Ports */} -
- -
- {COMMON_PORTS.map(({ name, port, desc }) => ( -
- handlePortToggle(index, name)} - className="font-text1 bg-checkmark h-3 w-3" - /> - {name} - - {/* Tooltip Icon */} - - - {/* Tooltip Box */} -
- Port {port}: {desc} -
-
- ))} -
-
- - {/* Custom Ports */} -
- -
- - handleCustomPortsChange(index, e.target.value) - } - className="font-text1 flex-grow h-9 text-xs p-2 border rounded" - /> - - {/* Drop Button */} -
- -
-
-
+ + + + + + +
)) diff --git a/src/app/instances/[name]/hardware/InstanceTypePage.tsx b/src/app/instances/[name]/hardware/components/InstanceTypePage.tsx similarity index 97% rename from src/app/instances/[name]/hardware/InstanceTypePage.tsx rename to src/app/instances/[name]/hardware/components/InstanceTypePage.tsx index f3d8ec0..478f882 100644 --- a/src/app/instances/[name]/hardware/InstanceTypePage.tsx +++ b/src/app/instances/[name]/hardware/components/InstanceTypePage.tsx @@ -1,10 +1,10 @@ "use client"; -import { useInstanceContext } from "../InstanceContext"; +import { useInstanceContext } from "../../InstanceContext"; import { useEffect, useState } from "react"; import axios from "axios"; -import ErrorBanner from "@/app/components/ErrorBanner"; -import SubmissionSpinner from "@/app/components/SubmissionSpinner"; +import ErrorBanner from "@/app/instances/components/ErrorBanner"; +import SubmissionSpinner from "../../../components/SubmissionSpinner"; type InstanceTypes = Record; diff --git a/src/app/instances/[name]/hardware/StoragePage.tsx b/src/app/instances/[name]/hardware/components/StoragePage.tsx similarity index 90% rename from src/app/instances/[name]/hardware/StoragePage.tsx rename to src/app/instances/[name]/hardware/components/StoragePage.tsx index 47012b8..36a2349 100644 --- a/src/app/instances/[name]/hardware/StoragePage.tsx +++ b/src/app/instances/[name]/hardware/components/StoragePage.tsx @@ -3,14 +3,14 @@ import { useEffect, useState } from "react"; import { useNotificationsContext } from "@/app/NotificationContext"; -import { useInstanceContext } from "../InstanceContext"; +import { useInstanceContext } from "../../InstanceContext"; import axios from "axios"; import { useRouter } from "next/navigation"; import React from "react"; -import { StorageDetails } from "@/app/components/StorageDetails"; -import ErrorBanner from "@/app/components/ErrorBanner"; -import SubmissionSpinner from "@/app/components/SubmissionSpinner"; +import { StorageDetails } from "../../../components/StorageDetails"; +import ErrorBanner from "@/app/instances/components/ErrorBanner"; +import SubmissionSpinner from "../../../components/SubmissionSpinner"; export function StoragePage() { const router = useRouter(); @@ -166,11 +166,10 @@ export function StoragePage() {
+ )} + + {copied && ( +
+ Copied logs to clipboard +
+ )} + +
+
+          {logs}
+        
+
+
+ ) +} diff --git a/src/app/instances/[name]/logs/components/LoadingSkeleton.tsx b/src/app/instances/[name]/logs/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..e4fda55 --- /dev/null +++ b/src/app/instances/[name]/logs/components/LoadingSkeleton.tsx @@ -0,0 +1,9 @@ +export default function LoadingSkeleton() { + return ( +
+ {Array.from({ length: 20 }).map((_, index) => ( +
+ ))} +
+ ) +} diff --git a/src/app/instances/[name]/logs/components/LogsDescription.tsx b/src/app/instances/[name]/logs/components/LogsDescription.tsx new file mode 100644 index 0000000..a7bdb4c --- /dev/null +++ b/src/app/instances/[name]/logs/components/LogsDescription.tsx @@ -0,0 +1,14 @@ +export default function LogsDescription() { + return ( + <> +

+ RabbitMQ Logs +

+

+ The following logs are fetched from the RabbitMQ server on your running + instance. They are displayed here for convenient debugging. You can also + copy your logs as needed. +

+ + ) +} diff --git a/src/app/instances/[name]/logs/page.tsx b/src/app/instances/[name]/logs/page.tsx index 2d7a3c1..9fa0716 100644 --- a/src/app/instances/[name]/logs/page.tsx +++ b/src/app/instances/[name]/logs/page.tsx @@ -3,7 +3,9 @@ import { useEffect, useState, useRef } from "react"; import { useInstanceContext } from "../InstanceContext"; import axios from "axios"; -import { Copy } from "lucide-react"; +import LogsDescription from "./components/LogsDescription"; +import LoadingSkeleton from "./components/LoadingSkeleton"; +import CopyableLogSection from "./components/CopyableLogSection"; export default function LogsPage() { const { instance } = useInstanceContext(); @@ -11,7 +13,7 @@ export default function LogsPage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); - const scrollContainerRef = useRef(null); + const scrollContainerRef = useRef(null); useEffect(() => { const fetchLogs = async () => { @@ -45,14 +47,6 @@ export default function LogsPage() { } }, [logs]); - const secureWindow = () => { - return ( - window !== undefined && - (new RegExp(/^https/).test(window.location.href) || - new RegExp(/^http:\/\/localhost/).test(window.location.href)) - ); - }; - const handleCopy = () => { navigator.clipboard.writeText(logs); setCopied(true); @@ -61,49 +55,19 @@ export default function LogsPage() { return (
-

- RabbitMQ Logs -

-

- The following logs are fetched from the RabbitMQ server on your running - instance. They are displayed here for convenient debugging. You can also - copy your logs as needed. -

+ {isLoading ? ( -
- {Array.from({ length: 20 }).map((_, index) => ( -
- ))} -
+ ) : error ? (

{error}

) : ( -
- {secureWindow() && ( - - )} - - {copied && ( -
- Copied logs to clipboard -
- )} - -
-
-              {logs}
-            
-
-
+ )}
); diff --git a/src/app/instances/[name]/page.tsx b/src/app/instances/[name]/page.tsx index 084cca0..cb714b5 100644 --- a/src/app/instances/[name]/page.tsx +++ b/src/app/instances/[name]/page.tsx @@ -1,72 +1,16 @@ "use client"; import React from "react"; -import formatDate from "@/utils/formatDate"; import { useInstanceContext } from "./InstanceContext"; -import { Copy, Eye, EyeOff } from "lucide-react"; -import { useState } from "react"; +import InstanceInfo from "./components/InstanceInfo"; +import ServerInfo from "./components/ServerInfo"; export default function InstancePage() { const { instance } = useInstanceContext(); - const [showCopied, setShowCopied] = useState(false); - const [showPassword, setShowPassword] = useState(false); - const [showUrlPassword, setShowUrlPassword] = useState(false); - const handleCopy = async () => { - if (!instance?.endpointUrl) return; - await navigator.clipboard.writeText(instance.endpointUrl); - setShowCopied(true); - setTimeout(() => setShowCopied(false), 2000); - }; - - const secureWindow = () => { - return ( - window !== undefined && - (new RegExp(/^https/).test(window.location.href) || - new RegExp(/^http:\/\/localhost/).test(window.location.href)) - ); - }; - - const togglePassword = () => { - setShowPassword((prev) => !prev); - }; - - const toggleUrlPassword = () => { - setShowUrlPassword((prev) => !prev); - }; - - const getDisplayedEndpointUrl = (): string => { - if (!instance || !instance.endpointUrl) return ""; - - try { - const full = instance.endpointUrl; - - const protocolSplit = full.split("://"); - if (protocolSplit.length !== 2) return full; - - const protocol = protocolSplit[0] + "://"; - const rest = protocolSplit[1]; - - const atIndex = rest.lastIndexOf("@"); - if (atIndex === -1) return full; - - const credentials = rest.slice(0, atIndex); - const hostAndPath = rest.slice(atIndex + 1); - - const [username, rawPassword] = credentials.split(":"); - - if (!username || !rawPassword) return full; - - const displayedPassword = showUrlPassword - ? decodeURIComponent(rawPassword) - : "•".repeat(decodeURIComponent(rawPassword).length || 8); - - return `${protocol}${username}:${displayedPassword}@${hostAndPath}`; - } catch (error) { - console.error("Error parsing endpoint URL:", error); - return instance.endpointUrl; - } - }; + if (!instance) { + throw new Error("Instance not found"); + } return (
@@ -74,117 +18,8 @@ export default function InstancePage() { {instance?.name} -

- Instance Info -

- - - - - - - - - - - - - - - - - - - - - - - -
Status: - {instance?.state} -
Host:{instance?.publicDns}
Created at: - {instance?.launchTime && formatDate(instance?.launchTime)} -
Data Center:{instance?.region}
- -

- RabbitMQ Server Info: -

- - - - - - - - - - - - - - - - - - - - - - - -
Port:{instance?.port}
User:{instance?.user}
Password: - - {showPassword - ? instance?.password - : "•".repeat(instance?.password?.length || 8)} - - -
RabbitMQ Connection URL: -
- {getDisplayedEndpointUrl()} - -
- {secureWindow() && ( - - )} - {showCopied && ( -
- RabbitMQ URL copied to clipboard -
- )} -
-
-
+ +
); } diff --git a/src/app/instances/[name]/plugins/components/LoadingSkeleton.tsx b/src/app/instances/[name]/plugins/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..99ad266 --- /dev/null +++ b/src/app/instances/[name]/plugins/components/LoadingSkeleton.tsx @@ -0,0 +1,20 @@ +export default function LoadingSkeleton({ length }: { length: number }) { + return ( +
+ {[...Array(length)].map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) +} diff --git a/src/app/instances/[name]/plugins/components/PluginEntry.tsx b/src/app/instances/[name]/plugins/components/PluginEntry.tsx new file mode 100644 index 0000000..e51b850 --- /dev/null +++ b/src/app/instances/[name]/plugins/components/PluginEntry.tsx @@ -0,0 +1,56 @@ +import { Plugin } from "@/types/plugins"; + +interface Props { + plugin: Plugin; + onSubmit: (e: React.FormEvent, pluginName: string) => void; + isEnabled: boolean; + disabled: boolean; +} + +export default function PluginEntry({ + plugin, + onSubmit, + isEnabled, + disabled, +}: Props) { + return ( + onSubmit(e, plugin.name)} + className="flex flex-col md:flex-row items-center justify-between border-b border-gray-300 pb-4" + > +
+

+ {plugin.name} +

+

+ {plugin.description} +

+
+
+ +
+ + ) +} diff --git a/src/app/instances/[name]/plugins/components/PluginsDescription.tsx b/src/app/instances/[name]/plugins/components/PluginsDescription.tsx new file mode 100644 index 0000000..d2c4a79 --- /dev/null +++ b/src/app/instances/[name]/plugins/components/PluginsDescription.tsx @@ -0,0 +1,22 @@ +export default function PluginsDescription() { + return ( + <> +

Plugins

+

+ Below is a list of RabbitMQ plugins that you can enable or disable. + Toggling a plugin will immediately update its status on this page and + within your RabbitMQ instance. For more detailed information on RabbitMQ + plugins and their management, refer to the{" "} + + RabbitMQ Plugins Guide + + . +

+ + ) +} diff --git a/src/app/instances/[name]/plugins/page.tsx b/src/app/instances/[name]/plugins/page.tsx index 382a23c..5719b71 100644 --- a/src/app/instances/[name]/plugins/page.tsx +++ b/src/app/instances/[name]/plugins/page.tsx @@ -5,6 +5,9 @@ import { useEffect, useState } from "react"; import { plugins, Plugin } from "@/types/plugins"; import { useInstanceContext } from "../InstanceContext"; import { useNotificationsContext } from "@/app/NotificationContext"; +import PluginsDescription from "./components/PluginsDescription"; +import LoadingSkeleton from "./components/LoadingSkeleton"; +import PluginEntry from "./components/PluginEntry"; export default function PluginsPage() { const { instance } = useInstanceContext(); @@ -46,7 +49,7 @@ export default function PluginsPage() { const currentlyEnabled = enabledPlugins.includes(pluginName); const newValue = !currentlyEnabled; - await addNotification({ + addNotification({ type: "plugin", status: "pending", instanceName: instance?.name, @@ -54,8 +57,6 @@ export default function PluginsPage() { message: `${newValue ? "Enabling" : "Disabling"} ${pluginName}`, }); - //update the state immediately, - // we do this so that the toggle button updates immediately. setEnabledPlugins((prev) => newValue ? [...prev, pluginName] : prev.filter((p) => p !== pluginName) ); @@ -82,84 +83,22 @@ export default function PluginsPage() { return (
-

Plugins

-

- Below is a list of RabbitMQ plugins that you can enable or disable. - Toggling a plugin will immediately update its status on this page and - within your RabbitMQ instance. For more detailed information on RabbitMQ - plugins and their management, refer to the{" "} - - RabbitMQ Plugins Guide - - . -

+ {isLoading ? ( -
- {[...Array(plugins.length)].map((_, index) => ( -
-
-
-
-
-
-
-
-
- ))} -
+ ) : (
{plugins.map((plugin: Plugin) => { const isEnabled = enabledPlugins.includes(plugin.name); return ( -
handleSubmit(e, plugin.name)} - className="flex flex-col md:flex-row items-center justify-between border-b border-gray-300 pb-4" - > -
-

- {plugin.name} -

-

- {plugin.description} -

-
-
- -
-
+ plugin={plugin} + onSubmit={handleSubmit} + isEnabled={isEnabled} + disabled={formPending()} + /> ); })}
diff --git a/src/app/instances/[name]/versions/components/LoadingSkeleton.tsx b/src/app/instances/[name]/versions/components/LoadingSkeleton.tsx new file mode 100644 index 0000000..6ceac41 --- /dev/null +++ b/src/app/instances/[name]/versions/components/LoadingSkeleton.tsx @@ -0,0 +1,22 @@ +export default function LoadingSkeleton() { + return ( + <> + + +
+ + +
+ + + + +
+ + +
+ + + + ) +} diff --git a/src/app/instances/[name]/versions/components/VersionsDescription.tsx b/src/app/instances/[name]/versions/components/VersionsDescription.tsx new file mode 100644 index 0000000..711f704 --- /dev/null +++ b/src/app/instances/[name]/versions/components/VersionsDescription.tsx @@ -0,0 +1,23 @@ +export default function VersionsDescription() { + return ( + <> +

+ Versions +

+

+ Currently, this interface does not support upgrading RabbitMQ versions. + For detailed instructions on how to manually upgrade RabbitMQ, please + refer to the official RabbitMQ upgrade guide:{" "} + + RabbitMQ Upgrade Guide + + . +

+ + ) +} diff --git a/src/app/instances/[name]/versions/page.tsx b/src/app/instances/[name]/versions/page.tsx index 6e73f6c..00a0f1a 100644 --- a/src/app/instances/[name]/versions/page.tsx +++ b/src/app/instances/[name]/versions/page.tsx @@ -3,6 +3,8 @@ import React from "react"; import axios from "axios"; import { useEffect, useState } from "react"; import { useInstanceContext } from "../InstanceContext"; +import VersionsDescription from "./components/VersionsDescription"; +import LoadingSkeleton from "./components/LoadingSkeleton"; interface Version { rabbitmq: string; @@ -44,23 +46,7 @@ export default function VersionsPage() { return (
-

- Versions -

-

- Currently, this interface does not support upgrading RabbitMQ versions. - For detailed instructions on how to manually upgrade RabbitMQ, please - refer to the official RabbitMQ upgrade guide:{" "} - - RabbitMQ Upgrade Guide - - . -

+
@@ -70,29 +56,11 @@ export default function VersionsPage() { {isLoading ? ( - <> - - - - - - - - - + ) : versions ? ( <> diff --git a/src/app/instances/components/CreateNewInstanceButton.tsx b/src/app/instances/components/CreateNewInstanceButton.tsx new file mode 100644 index 0000000..7fe429b --- /dev/null +++ b/src/app/instances/components/CreateNewInstanceButton.tsx @@ -0,0 +1,22 @@ +import Link from "next/link"; + +export default function CreateNewInstanceButton({ isGlowing }: { isGlowing: boolean }) { + return ( +
+

Instances

+ + + +
+ ) +} diff --git a/src/app/instances/components/DeleteInstanceModal.tsx b/src/app/instances/components/DeleteInstanceModal.tsx new file mode 100644 index 0000000..e6c04e3 --- /dev/null +++ b/src/app/instances/components/DeleteInstanceModal.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import SubmissionSpinner from "../components/SubmissionSpinner"; +import { Instance } from "@/types/instance"; + +interface DeleteInstanceModalProps { + selectedInstance: Instance; + onClose: () => void; + handleDelete: () => void; + isDeleting: boolean; +} + +export default function DeleteInstanceModal({ + selectedInstance, + onClose, + handleDelete, + isDeleting, +}: DeleteInstanceModalProps) { + const [inputText, setInputText] = useState(""); + + return ( +
+
+

+ Delete {selectedInstance.name}? +

+

+ Deleting this instance is permanent and will result in the loss of + all data stored on it. This action cannot be undone. +

+

+ Type {selectedInstance.name} to confirm deletion. +

+ setInputText(e.target.value)} + /> +
+ + +
+
+
+ + ) +} diff --git a/src/app/components/ErrorBanner.tsx b/src/app/instances/components/ErrorBanner.tsx similarity index 100% rename from src/app/components/ErrorBanner.tsx rename to src/app/instances/components/ErrorBanner.tsx diff --git a/src/app/components/InstanceDetails.tsx b/src/app/instances/components/InstanceDetails.tsx similarity index 100% rename from src/app/components/InstanceDetails.tsx rename to src/app/instances/components/InstanceDetails.tsx diff --git a/src/app/instances/components/InstanceRow.tsx b/src/app/instances/components/InstanceRow.tsx new file mode 100644 index 0000000..9441a26 --- /dev/null +++ b/src/app/instances/components/InstanceRow.tsx @@ -0,0 +1,83 @@ +import { Trash2 } from "lucide-react"; +import Link from "next/link"; +import { Instance } from "@/types/instance"; + +interface InstanceRowProps { + instance: Instance; + openDeleteModal: (instance: Instance) => void; +} + + +export default function InstanceRow({ + instance, + openDeleteModal +}: InstanceRowProps) { + return ( + + + + + + + + ) +} diff --git a/src/app/instances/components/InstancesTable.tsx b/src/app/instances/components/InstancesTable.tsx new file mode 100644 index 0000000..dc1a8b5 --- /dev/null +++ b/src/app/instances/components/InstancesTable.tsx @@ -0,0 +1,65 @@ +import { StatusLegend } from "@/app/components/statusLegend" +import InstanceRow from "./InstanceRow" +import { Instance } from "@/types/instance"; + +interface InstancesTableProps { + isLoading: boolean; + instances: Instance[]; + openDeleteModal: (instance: Instance) => void; +} + + +export default function InstancesTable({ + isLoading, + instances, + openDeleteModal +}: InstancesTableProps) { + return ( +
-
-
-
-
-
-
-
-
+ {instance.state === "pending" || + instance.state === "shutting-down" || + instance.state === "terminated" ? ( + + {instance.name} + + ) : ( + + {instance.name} + + )} + + {instance.id} + + {instance.region} + + {instance.state} + + +
+ + + + + + + + + + + + {isLoading + ? Array.from({ length: 3 }).map((_, idx) => ( + + + + + + + + )) + : instances.map((instance) => ( + + ))} + +
NameInstance IDData Center + Status +
+
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/src/app/components/StorageDetails.tsx b/src/app/instances/components/StorageDetails.tsx similarity index 100% rename from src/app/components/StorageDetails.tsx rename to src/app/instances/components/StorageDetails.tsx diff --git a/src/app/components/SubmissionSpinner.tsx b/src/app/instances/components/SubmissionSpinner.tsx similarity index 100% rename from src/app/components/SubmissionSpinner.tsx rename to src/app/instances/components/SubmissionSpinner.tsx diff --git a/src/app/instances/new/components/InstanceNameField.tsx b/src/app/instances/new/components/InstanceNameField.tsx new file mode 100644 index 0000000..1b186fa --- /dev/null +++ b/src/app/instances/new/components/InstanceNameField.tsx @@ -0,0 +1,40 @@ +interface InstanceNameFieldProps { + instanceName: string; + onGenerate: () => void; + onChange: (name: string) => void; +} + +export default function InstanceNameField({ + instanceName, + onGenerate, + onChange, +}: InstanceNameFieldProps) { + return ( +
+ +
+ onChange(e.target.value)} + className="font-text1 text-btnhover1 w-9/16 p-2 border border-pagetext1 rounded-md text-sm" + /> + +
+
+ + ) +} diff --git a/src/app/instances/new/components/InstanceSizeField.tsx b/src/app/instances/new/components/InstanceSizeField.tsx new file mode 100644 index 0000000..1a24e1d --- /dev/null +++ b/src/app/instances/new/components/InstanceSizeField.tsx @@ -0,0 +1,39 @@ +interface InstanceSizeFieldProps { + selectedInstanceSize: string; + selectedInstanceType: string; + filteredInstanceTypes: string[]; + onChange: (size: string) => void; +} + +export default function InstanceSizeField({ + selectedInstanceSize, + selectedInstanceType, + filteredInstanceTypes, + onChange, +}: InstanceSizeFieldProps) { + return ( +
+ + +
+ ) +} diff --git a/src/app/instances/new/components/InstanceTypeField.tsx b/src/app/instances/new/components/InstanceTypeField.tsx new file mode 100644 index 0000000..c2b0159 --- /dev/null +++ b/src/app/instances/new/components/InstanceTypeField.tsx @@ -0,0 +1,36 @@ +interface InstanceTypeFieldProps { + selectedInstanceType: string; + instanceTypes: Record; + onChange: (type: string) => void; +} + +export default function InstanceTypeField({ + selectedInstanceType, + instanceTypes, + onChange, +}: InstanceTypeFieldProps) { + return ( +
+ + +
+ ) +} diff --git a/src/app/instances/new/NewInstanceLoadingSkeleton.tsx b/src/app/instances/new/components/NewInstanceLoadingSkeleton.tsx similarity index 100% rename from src/app/instances/new/NewInstanceLoadingSkeleton.tsx rename to src/app/instances/new/components/NewInstanceLoadingSkeleton.tsx diff --git a/src/app/instances/new/components/PasswordField.tsx b/src/app/instances/new/components/PasswordField.tsx new file mode 100644 index 0000000..0b5f327 --- /dev/null +++ b/src/app/instances/new/components/PasswordField.tsx @@ -0,0 +1,28 @@ +interface PasswordFieldProps { + password: string; + onChange: (password: string) => void; +} + +export default function PasswordField({ + password, + onChange, +}: PasswordFieldProps) { + return ( +
+ + onChange(e.target.value)} + className="font-text1 w-3/4 p-2 border rounded-md text-sm" + /> +
+ ) +} diff --git a/src/app/instances/new/components/RegionField.tsx b/src/app/instances/new/components/RegionField.tsx new file mode 100644 index 0000000..924c9a2 --- /dev/null +++ b/src/app/instances/new/components/RegionField.tsx @@ -0,0 +1,36 @@ +interface RegionFieldProps { + selectedRegion: string; + availableRegions: string[]; + onChange: (region: string) => void; +} + +export default function RegionField({ + selectedRegion, + availableRegions, + onChange, +}: RegionFieldProps) { + return ( +
+ + +
+ ) +} diff --git a/src/app/instances/new/components/StorageSizeField.tsx b/src/app/instances/new/components/StorageSizeField.tsx new file mode 100644 index 0000000..e2df868 --- /dev/null +++ b/src/app/instances/new/components/StorageSizeField.tsx @@ -0,0 +1,28 @@ +interface StorageSizeFieldProps { + storageSize: number; + onChange: (size: number) => void; +} + +export default function StorageSizeField({ + storageSize, + onChange, +}: StorageSizeFieldProps) { + return ( +
+ + onChange(Number(e.target.value))} + className="font-text1 w-3/4 p-2 border rounded-md text-sm" + /> +
+ ) +} diff --git a/src/app/instances/new/components/UsernameField.tsx b/src/app/instances/new/components/UsernameField.tsx new file mode 100644 index 0000000..301be09 --- /dev/null +++ b/src/app/instances/new/components/UsernameField.tsx @@ -0,0 +1,28 @@ +interface UsernameFieldProps { + username: string; + onChange: (username: string) => void; +} + +export default function UsernameField({ + username, + onChange, +}: UsernameFieldProps) { + return ( +
+ + onChange(e.target.value)} + className="font-text1 w-3/4 p-2 border rounded-md text-sm" + /> +
+ ) +} diff --git a/src/app/instances/new/page.tsx b/src/app/instances/new/page.tsx index 8367d7f..0b48b90 100644 --- a/src/app/instances/new/page.tsx +++ b/src/app/instances/new/page.tsx @@ -8,13 +8,20 @@ import axios from "axios"; import Link from "next/link"; import { Lightbulb } from "lucide-react"; -import { StorageDetails } from "@/app/components/StorageDetails"; -import { InstanceDetails } from "@/app/components/InstanceDetails"; -import ErrorBanner from "@/app/components/ErrorBanner"; -import NewInstanceLoadingSkeleton from "./NewInstanceLoadingSkeleton"; -import SubmissionSpinner from "@/app/components/SubmissionSpinner"; +import { StorageDetails } from "../components/StorageDetails"; +import { InstanceDetails } from "../components/InstanceDetails"; +import ErrorBanner from "@/app/instances/components/ErrorBanner"; +import NewInstanceLoadingSkeleton from "./components/NewInstanceLoadingSkeleton"; +import SubmissionSpinner from "../components/SubmissionSpinner"; import { useNotificationsContext } from "@/app/NotificationContext"; +import InstanceNameField from "./components/InstanceNameField"; +import RegionField from "./components/RegionField"; +import InstanceTypeField from "./components/InstanceTypeField"; +import InstanceSizeField from "./components/InstanceSizeField"; +import StorageSizeField from "./components/StorageSizeField"; +import UsernameField from "./components/UsernameField"; +import PasswordField from "./components/PasswordField"; type InstanceTypes = Record; @@ -77,37 +84,37 @@ export default function NewInstancePage() { const validateName = (name: string) => !/^[a-z0-9-_]{3,64}$/i.test(name) ? [ - "Instance name must be 3–64 characters long and use only letters, numbers, hyphens, or underscores.", - ] + "Instance name must be 3–64 characters long and use only letters, numbers, hyphens, or underscores.", + ] : []; const validateRegion = (region: string) => !region ? ["Please select a region."] : !availableRegions.includes(region) - ? ["Selected region is not valid."] - : []; + ? ["Selected region is not valid."] + : []; const validateInstanceType = (type: string) => !type ? ["Please select an instance type."] : !(type in instanceTypes) - ? ["Selected instance type is not valid."] - : []; + ? ["Selected instance type is not valid."] + : []; const validateSize = (size: string) => !size ? ["Please select an instance size."] : !filteredInstanceTypes.includes(size) - ? ["Selected instance size is not valid."] - : []; + ? ["Selected instance size is not valid."] + : []; const validateUsername = (u: string) => !u ? ["Username is required."] : u.length < 6 - ? ["Username must be at least 6 characters long."] - : []; + ? ["Username must be at least 6 characters long."] + : []; const validatePassword = (p: string) => !p @@ -116,10 +123,10 @@ export default function NewInstancePage() { !/[a-zA-Z]/.test(p) || !/[0-9]/.test(p) || !/[!@#$%^&*]/.test(p) - ? [ + ? [ "Password must be at least 8 characters long and include a letter, a number, and a special character.", ] - : []; + : []; const validateStorageSize = (size: number) => isNaN(size) || size < 8 || size > 16000 @@ -147,7 +154,7 @@ export default function NewInstancePage() { } try { - await addNotification({ + addNotification({ type: "newInstance", status: "pending", instanceName, @@ -174,8 +181,10 @@ export default function NewInstancePage() { }; const handleGenerate = () => setInstanceName(generateName()); - const dismissError = (i: number) => + + const dismissError = (i: number) => { setErrorMessages((prev) => prev.filter((_, idx) => idx !== i)); + }; return (
- {/* Instance Name */} -
- -
- setInstanceName(e.target.value)} - className="font-text1 text-btnhover1 w-9/16 p-2 border border-pagetext1 rounded-md text-sm" - /> - -
-
+ - {/* Region */} -
- - -
+ - {/* Instance Type */} -
- - -
- - {/* Instance Size */} -
- - -
+ + - {/* Storage Size */} -
- - setStorageSize(Number(e.target.value))} - className="font-text1 w-3/4 p-2 border rounded-md text-sm" - /> -
+

Create a username and password for logging into your RabbitMQ Management UI.

- {/* Username */} -
- - setUsername(e.target.value)} - className="font-text1 w-3/4 p-2 border rounded-md text-sm" - /> -
+ - {/* Password */} -
- - setPassword(e.target.value)} - className="font-text1 w-3/4 p-2 border rounded-md text-sm" - /> -
+

Password must be at least 8 characters long and include one letter, one number, and one special character ( !@#$%^&* ) . @@ -381,7 +271,6 @@ export default function NewInstancePage() {

- {/* Buttons */}
([]); @@ -22,7 +15,6 @@ export default function Home() { const [selectedInstance, setSelectedInstance] = useState( null ); - const [inputText, setInputText] = useState(""); const [isDeleting, setIsDeleting] = useState(false); const { notifications, @@ -79,20 +71,18 @@ export default function Home() { const openDeleteModal = (instance: Instance) => { setSelectedInstance(instance); - setInputText(""); setShowModal(true); }; const closeDeleteModal = () => { setShowModal(false); setSelectedInstance(null); - setInputText(""); }; const handleDelete = async () => { if (!selectedInstance) return; - await addNotification({ + addNotification({ type: "deleteInstance", status: "pending", instanceName: selectedInstance.name, @@ -122,132 +112,13 @@ export default function Home() { return (
-
-

Instances

- - - -
- - - - - - - - - - - + + - - {isLoading - ? Array.from({ length: 3 }).map((_, idx) => ( - - - - - - - - )) - : instances.map((instance) => ( - - - - - - - - ))} - -
NameInstance IDData Center - Status -
-
-
-
-
-
-
-
-
-
-
- {instance.state === "pending" || - instance.state === "shutting-down" || - instance.state === "terminated" ? ( - - {instance.name} - - ) : ( - - {instance.name} - - )} - - {instance.id} - - {instance.region} - - {instance.state} - - -
{!isLoading && instances.length === 0 && (

No instances yet. Let’s spin one up! @@ -255,48 +126,7 @@ export default function Home() { )} {showModal && selectedInstance && ( -

-
-

- Delete {selectedInstance.name}? -

-

- Deleting this instance is permanent and will result in the loss of - all data stored on it. This action cannot be undone. -

-

- Type {selectedInstance.name} to confirm deletion. -

- setInputText(e.target.value)} - /> -
- - -
-
-
+ )}