diff --git a/README.md b/README.md index 668ac99..91ea00c 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,15 @@ A distributed file storage system built with **Spring Boot** that demonstrates c │ ▼ Gateway (:8080) ──────────────► ZAB Metadata Cluster - │ ├─ Node 1 (:8081) [Leader] - │ ├─ Node 2 (:8082) [Follower] - │ └─ Node 3 (:8083) [Follower] - │ - ├──► Storage Node 1 (:9001) - ├──► Storage Node 2 (:9002) - ├──► Storage Node 3 (:9003) - └──► Storage Node 4 (:9004) + + │ ├─ Node 1 (:8081) [Leader] + │ ├─ Node 2 (:8082) [Follower] + │ └─ Node 3 (:8083) [Follower] + │ + ├──► Storage Node 1 (:9001) + ├──► Storage Node 2 (:9002) + ├──► Storage Node 3 (:9003) + └──► Storage Node 4 (:9004) ``` | Layer | Role | diff --git a/demo-ui/index.html b/demo-ui/index.html index 0f649fb..05ec2be 100644 --- a/demo-ui/index.html +++ b/demo-ui/index.html @@ -2,9 +2,9 @@ - + - demo-ui + Needo
diff --git a/demo-ui/public/needo.png b/demo-ui/public/needo.png new file mode 100644 index 0000000..4e1a33c Binary files /dev/null and b/demo-ui/public/needo.png differ diff --git a/demo-ui/src/App.tsx b/demo-ui/src/App.tsx index b4c0272..4754144 100644 --- a/demo-ui/src/App.tsx +++ b/demo-ui/src/App.tsx @@ -1,10 +1,15 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; -import { HardDrive, Cloud, Server, UploadCloud, Trash2, Download, Activity, CheckCircle2, XCircle, File as FileIcon, Box, X } from 'lucide-react'; +import { + Cloud, HardDrive, LayoutGrid as Layout, Server, Upload, Trash2, + Download, Activity, CheckCircle, XCircle, File as FileIcon, + X, Zap, Shield, Info, RefreshCw +} from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; const API_URL = 'http://localhost:8080'; +// Types interface NodeHealth { upNodesCount: number; downNodesCount: number; @@ -34,18 +39,41 @@ interface ClusterHealth { interface Manifest { fileId: string; chunkIds: string[]; - replicas: Record; // chunkId -> list of node URLs + replicas: Record; version: number; timestamp: number; chunkCount: number; } +interface ModalState { + type: 'confirm' | 'alert' | 'error'; + title: string; + message: string; + onConfirm?: () => void; + onCancel?: () => void; + confirmLabel?: string; + cancelLabel?: string; + isDestructive?: boolean; +} + +interface NotificationState { + message: string; + type: 'success' | 'error'; +} + export default function App() { const [activeTab, setActiveTab] = useState<'files' | 'dashboard'>('files'); const [health, setHealth] = useState(null); const [files, setFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); const [selectedFileId, setSelectedFileId] = useState(null); + const [activeModal, setActiveModal] = useState(null); + const [notification, setNotification] = useState(null); + + const showNotification = (message: string, type: 'success' | 'error' = 'success') => { + setNotification({ message, type }); + setTimeout(() => setNotification(null), 4000); + }; // Poll Health useEffect(() => { @@ -91,9 +119,14 @@ export default function App() { try { await axios.post(`${API_URL}/files`, formData); + showNotification("Upload successful"); } catch (err) { - alert("Failed to upload file"); - console.error(err); + setActiveModal({ + type: 'error', + title: 'Upload Failed', + message: 'Could not complete the file upload. Please check your network connection.', + confirmLabel: 'OK' + }); } finally { setIsUploading(false); if (e.target) e.target.value = ''; @@ -102,390 +135,380 @@ export default function App() { const handleDownload = (filename: string) => { window.open(`${API_URL}/files/${filename}`, '_blank'); + showNotification("Download started"); }; const handleDelete = async (filename: string) => { - if (!confirm(`Are you sure you want to delete ${filename}?`)) return; - try { - await axios.delete(`${API_URL}/files/${filename}`); - } catch (e) { - alert("Failed to delete. Ensure ZAB metadata nodes are healthy."); - } + setActiveModal({ + type: 'confirm', + title: 'Delete Shard', + message: `Are you sure you want to permanently remove "${filename}" from the distributed archive?`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + isDestructive: true, + onConfirm: async () => { + try { + await axios.delete(`${API_URL}/files/${filename}`); + setActiveModal(null); + showNotification("File data deleted"); + } catch (e) { + setActiveModal({ + type: 'error', + title: 'Action Failed', + message: 'An error occurred while deleting the file chunks.', + confirmLabel: 'OK' + }); + } + } + }); }; const handleToggleNode = async (url: string) => { const port = url.split(':').pop(); const isCurrentlyUp = health?.storage.upNodeUrls.includes(url); - const action = isCurrentlyUp ? "Kill/Simulate Failure" : "Bring UP"; - - if (!confirm(`Confirm: ${action} for Node on port ${port}?`)) return; + const action = isCurrentlyUp ? "Deactivate" : "Activate"; - try { - // Direct call to the node's chaos endpoint - await axios.post(`${url}/chaos/toggle`); - // No alert needed, the UI will poll and update automatically - } catch (e) { - console.error("Failed to toggle node health", e); - alert("Error reaching node. If it was a hard crash (System.exit), you must restart it manually in terminal."); - } + setActiveModal({ + type: 'confirm', + title: `${action} Node ${port}`, + message: isCurrentlyUp + ? `Are you sure you want to deactivate Node ${port}? This will simulate a cluster partition or failure.` + : `Activate Node ${port} and rejoin it to the distributed cluster?`, + confirmLabel: isCurrentlyUp ? 'Deactivate' : 'Activate', + cancelLabel: 'Cancel', + isDestructive: isCurrentlyUp, + onConfirm: async () => { + try { + await axios.post(`${API_URL}/api/chaos/toggle`, { url }); + setActiveModal(null); + showNotification(`Node ${port} ${isCurrentlyUp ? 'deactivated' : 'activated'}`); + } catch (e) { + setActiveModal({ + type: 'error', + title: 'Command Failed', + message: 'The chaos command could not be delivered to the gateway.', + confirmLabel: 'OK' + }); + } + } + }); }; return ( -
- {/* Sidebar */} -
-
- - NeedoDrive +
+ {/* Apple-style Sidebar */} + {/* Main Content */} -
-
-

- {activeTab === 'files' ? 'Archive' : 'Pulse Monitor'} -

-
- {health && ( -
- - LAMPORT CLOCK: {health.lamportClock} -
- )} +
+
+
+

+ {activeTab === 'files' ? 'Archive' : 'Dashboard'} +

+

+ {activeTab === 'files' ? 'Distributed storage chunks.' : 'System replication health.'} +

+
+ +
{activeTab === 'files' && ( -
- +
+
)} + {health && (activeTab === 'dashboard') && ( +
+ CLOCK: {health.lamportClock} +
+ )}
-
+
{activeTab === 'files' ? ( -
-
- - - - - - - - - - - {files.length === 0 ? ( - - - - ) : files.map((f) => ( - - - - - - - ))} - -
IdentiferReplication StatusTimestampControl
- -

Cluster Vault Empty

-
-
- -
- {f.fileId} -
- - - {formatDistanceToNow(new Date(f.timestamp), { addSuffix: true })} - - - -
-
- - {/* Chunk Mapping Modal */} - {selectedFileId && ( -
-
- {(() => { - const file = files.find(f => f.fileId === selectedFileId); - if (!file) return null; - return ( - <> -
+
+ {files.length === 0 ? ( +
+ +

Your archive is empty.

+
+ ) : ( +
+ {files.map(f => ( +
+
+
+ +
-

- - Data Distribution: {file.fileId} -

-

Physical chunk mapping across cluster

+

{f.fileId}

+

+ {formatDistanceToNow(new Date(f.timestamp), { addSuffix: true })} • {f.chunkCount} Nodes +

+
+
-
-
-
- {file.chunkIds.map((chunkId, idx) => ( -
-
- - CHUNK_{idx + 1} - - {chunkId} -
-
- {file.replicas[chunkId]?.map(nodeUrl => { - const nodeNum = nodeUrl.split(':').pop(); - const isDown = health?.storage.downNodeUrls.includes(nodeUrl); - return ( -
- - Node: {nodeNum} -
- ); - })} -
-
- ))} -
-
-
-
- - ); - })()} -
-
- )} + +
+
+ ))} +
+ )} + + {/* Simple Apple Modal for Chunks */} + {selectedFileId && ( +
+
+ +

Chunk Distribution

+

{selectedFileId}

+ +
+ {files.find(f => f.fileId === selectedFileId)?.chunkIds.map((cid, idx) => ( +
+ Chunk {idx + 1} +
+ {files.find(f => f.fileId === selectedFileId)?.replicas[cid]?.map(url => ( +
+
+ Port {url.split(':').pop()} +
+ ))} +
+
+ ))} +
+
+
+ )}
) : ( -
- {!health ? ( -
Syncing with Cluster...
- ) : ( - <> - {/* Overview Cards */} -
-
-
- -

Storage Status

-

- {health.storage.upNodesCount} / {health.storage.totalNodes} -

-
- - QUORUM SAFE +
+ {health ? ( + <> + {/* Balanced Status Cards - Compact */} +
+
+
+ + Storage Cluster +
+

+ {health.storage.upNodesCount} / {health.storage.totalNodes} +

-
- -
-
- -

Consistency Quorum

-

- W={health.storage.writeQuorum} | R={health.storage.readQuorum} -

-
- - STRONG CONSISTENCY (W+R > N) +
+
+ + Read/Write +
+

+ W{health.storage.writeQuorum} R{health.storage.readQuorum} +

-
- -
-
- -

Consensus Leader

-

- {health.zabCluster.leader ? health.zabCluster.leader.split('-').pop() : "NONE"} -

-
- - ZAB PROTOCOL ACTIVE +
+
+ + Metadata Leader +
+

+ {health.zabCluster.leader ? `Metadata Node ${health.zabCluster.leader.split('-').pop()}` : "No Leader"} +

-
-
+
- {/* CHAOS CONTROLS */} -
-
-
-

Topology & Chaos

-

Force server crashes to test self-healing re-replication.

+ {/* Consistent Management Section - Compact Dark Edition */} +
+ {/* Subtle Apple-style background gradient/glass */} +
+ +
+
+

Storage Node Control

+

Tap a node to perform health checks.

+
+
-
- Fault-Tolerance Testing Active + +
+ {[9001, 9002, 9003, 9004].map(port => { + const isUp = health.storage.upNodeUrls.includes(`http://localhost:${port}`); + return ( + + ) + })}
-
- -
- {[9001, 9002, 9003, 9004].map((port) => { - const url = `http://localhost:${port}`; - const isUp = health.storage.upNodeUrls?.includes(url); - return ( -
-
- Node {port} -
-
-

- {isUp ? 'OPERATIONAL' : 'OFFLINE'} -

- -
- ); - })} -
-
+
- {/* ZAB Detailed List */} -
-

ZAB Consensus Ring

-
- {[1, 2, 3].map((i) => { - const nodeId = `metadata-${i}`; - const isUp = health.zabCluster?.nodesStatus?.[nodeId] === "UP"; - const isLeader = health.zabCluster?.leader === nodeId; - - return ( -
-
- - NODE {i} - {isLeader && LEADER} - - {isUp ? ( - - ) : ( - - )} -
-
Port 808{i}
-
-
- {isUp ? 'SYNCED' : 'PARTITIONED'} -
-
- ) - })} -
-
+ {/* Metadata Ring - Compact */} +
+

Zab Metadata Cluster

+
+ {[1, 2, 3].map(i => { + const nodeId = `metadata-${i}`; + const isUp = health.zabCluster?.nodesStatus?.[nodeId] === "UP"; + const isLeader = health.zabCluster?.leader === nodeId; + return ( +
+
+ {isUp ? : } +
+ Metadata Node {i} +

{isLeader ? 'Leader' : (isUp ? 'Follower' : 'Offline')}

+
+ ) + })} +
+
+ + ) : ( +
+

Verifying Connection...

+
+ )} +
+ )} +
+
+ {/* Custom Modal */} + {activeModal && ( +
+
+
+

{activeModal.title}

+

{activeModal.message}

+
+
+ {activeModal.type === 'confirm' ? ( + <> + + + ) : ( + )}
- )} -
-
+
+
+ )} + + {/* Success Notification */} + {notification && ( +
+
+ {notification.type === 'success' ? : } + {notification.message} +
+
+ )}
); } - diff --git a/demo-ui/src/index.css b/demo-ui/src/index.css index 3e738a5..12e9d45 100644 --- a/demo-ui/src/index.css +++ b/demo-ui/src/index.css @@ -1,15 +1,39 @@ @import "tailwindcss"; @theme { - --color-brand-50: #f0fdf4; - --color-brand-100: #dcfce7; - --color-brand-500: #22c55e; - --color-brand-600: #16a34a; - --font-sans: "Inter Tight", sans-serif; + --color-apple-50: #fbfbfb; + --color-apple-100: #f5f5f7; + --color-apple-200: #e8e8ed; + --color-apple-500: #86868b; + --color-apple-900: #1d1d1f; + + --color-status-success: #34c759; + --color-status-error: #ff3b30; + --color-status-warning: #ff9500; + + --font-sans: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + --radius-apple: 12px; + --radius-apple-lg: 20px; + --radius-apple-xl: 32px; } @layer base { body { - @apply bg-gray-50 text-gray-900 font-sans antialiased; + @apply bg-white text-apple-900 font-sans antialiased selection:bg-apple-200 selection:text-apple-900; + } +} + +@layer components { + .apple-card { + @apply bg-apple-50 border border-apple-100 shadow-sm rounded-apple-lg; + } + + .apple-button-primary { + @apply bg-apple-900 text-white rounded-full px-6 py-2 transition-all hover:bg-black active:scale-95; + } + + .apple-button-secondary { + @apply bg-apple-200 text-apple-900 rounded-full px-6 py-2 transition-all hover:bg-apple-100 active:scale-95; } } diff --git a/src/main/java/com/example/demo/apigateway/GatewayController.java b/src/main/java/com/example/demo/apigateway/GatewayController.java index 99a189f..fe9c4cd 100644 --- a/src/main/java/com/example/demo/apigateway/GatewayController.java +++ b/src/main/java/com/example/demo/apigateway/GatewayController.java @@ -109,6 +109,27 @@ private Map getDashboardStats() { return stats; } + /** + * Simulation: Chaos Toggle Proxy - Forward commands to nodes. + */ + @PostMapping("/api/chaos/toggle") + public ResponseEntity toggleNode(@RequestBody Map payload) { + String url = payload.get("url"); + if (url == null) return ResponseEntity.badRequest().body("No URL provided"); + + try { + HttpRequest req = HttpRequest.newBuilder(URI.create(url + "/chaos/toggle")) + .timeout(Duration.ofSeconds(2)) + .POST(BodyPublishers.noBody()) + .build(); + + HttpResponse res = client.send(req, BodyHandlers.ofString()); + return ResponseEntity.status(res.statusCode()).body(res.body()); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body("Proxy fail: " + e.getMessage()); + } + } + /** * List all uploaded files */ diff --git a/src/main/java/com/example/demo/consensus/ClusterCoordinator.java b/src/main/java/com/example/demo/consensus/ClusterCoordinator.java index 3b3b7d2..34a444e 100644 --- a/src/main/java/com/example/demo/consensus/ClusterCoordinator.java +++ b/src/main/java/com/example/demo/consensus/ClusterCoordinator.java @@ -4,7 +4,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.web.client.RestTemplate; import io.grpc.StatusRuntimeException; -import com.example.demo.cluster.StatusResponse; +import com.example.demo.cluster .StatusResponse; import java.util.*; import java.util.concurrent.atomic.AtomicBoolean;