|
35 | 35 | ); |
36 | 36 | } |
37 | 37 |
|
| 38 | + function DataExplorer({ user }) { |
| 39 | + const [objects, setObjects] = useState([]); |
| 40 | + const [selectedObject, setSelectedObject] = useState(null); |
| 41 | + const [data, setData] = useState([]); |
| 42 | + const [loading, setLoading] = useState(false); |
| 43 | + const [error, setError] = useState(null); |
| 44 | + |
| 45 | + const getHeaders = () => { |
| 46 | + const headers = { 'Content-Type': 'application/json' }; |
| 47 | + // Since this is the Admin Dashboard, we use the admin identity to browse all data. |
| 48 | + // In production, you might want to pass the actual user ID and handle specific permissions, |
| 49 | + // or verify the session on the backend and grant admin context. |
| 50 | + headers['x-user-id'] = 'admin'; |
| 51 | + return headers; |
| 52 | + }; |
| 53 | + |
| 54 | + const fetchObjects = () => { |
| 55 | + fetch('/api/object/_schema/object', { headers: getHeaders() }) |
| 56 | + .then(async res => { |
| 57 | + if (!res.ok) throw new Error(await res.text() || res.statusText); |
| 58 | + return res.json(); |
| 59 | + }) |
| 60 | + .then(result => { |
| 61 | + const objNames = Object.keys(result); |
| 62 | + setObjects(objNames); |
| 63 | + if (objNames.length > 0 && !selectedObject) { |
| 64 | + setSelectedObject(objNames[0]); |
| 65 | + } |
| 66 | + }) |
| 67 | + .catch(err => console.error("Failed to fetch schema:", err)); |
| 68 | + }; |
| 69 | + |
| 70 | + useEffect(() => { |
| 71 | + if (user) fetchObjects(); |
| 72 | + }, [user]); |
| 73 | + |
| 74 | + const fetchData = () => { |
| 75 | + if (!selectedObject) return; |
| 76 | + setLoading(true); |
| 77 | + setError(null); |
| 78 | + |
| 79 | + fetch(`/api/object/${selectedObject}`, { headers: getHeaders() }) |
| 80 | + .then(async res => { |
| 81 | + if (!res.ok) { |
| 82 | + const contentType = res.headers.get("content-type"); |
| 83 | + if (contentType && contentType.indexOf("application/json") !== -1) { |
| 84 | + const json = await res.json(); |
| 85 | + throw new Error(json.error || "Failed to load data"); |
| 86 | + } |
| 87 | + throw new Error(await res.text() || res.statusText); |
| 88 | + } |
| 89 | + return res.json(); |
| 90 | + }) |
| 91 | + .then(result => { |
| 92 | + // Normalize result to array |
| 93 | + const items = Array.isArray(result) ? result : (result.list || []); |
| 94 | + setData(items); |
| 95 | + }) |
| 96 | + .catch(err => { |
| 97 | + console.error(err); |
| 98 | + setError(err.message); |
| 99 | + setData([]); |
| 100 | + }) |
| 101 | + .finally(() => setLoading(false)); |
| 102 | + }; |
| 103 | + |
| 104 | + useEffect(() => { |
| 105 | + if (user) fetchData(); |
| 106 | + }, [selectedObject, user]); |
| 107 | + |
| 108 | + return ( |
| 109 | + <div className="flex h-[calc(100vh-140px)] gap-6"> |
| 110 | + {/* Object List Sidebar */} |
| 111 | + <div className="w-64 flex-shrink-0 bg-white rounded-xl border border-gray-200/60 shadow-sm overflow-hidden flex flex-col"> |
| 112 | + <div className="p-4 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center"> |
| 113 | + <h3 className="font-semibold text-gray-900 text-xs uppercase tracking-wide">Database Objects</h3> |
| 114 | + <button onClick={fetchObjects} className="text-gray-400 hover:text-blue-600 transition-colors" title="Refresh Objects"> |
| 115 | + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M23 4v6h-6"></path><path d="M1 20v-6h6"></path><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg> |
| 116 | + </button> |
| 117 | + </div> |
| 118 | + <div className="overflow-y-auto flex-1 p-2 space-y-1"> |
| 119 | + {objects.length === 0 ? ( |
| 120 | + <div className="p-4 text-center text-gray-400 text-sm">No objects found</div> |
| 121 | + ) : ( |
| 122 | + objects.map(obj => ( |
| 123 | + <button |
| 124 | + key={obj} |
| 125 | + onClick={() => setSelectedObject(obj)} |
| 126 | + className={`w-full text-left px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ |
| 127 | + selectedObject === obj |
| 128 | + ? 'bg-blue-50 text-blue-700' |
| 129 | + : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900' |
| 130 | + }`} |
| 131 | + > |
| 132 | + {obj} |
| 133 | + </button> |
| 134 | + )) |
| 135 | + )} |
| 136 | + </div> |
| 137 | + </div> |
| 138 | + |
| 139 | + {/* Data Table Area */} |
| 140 | + <div className="flex-1 bg-white rounded-xl border border-gray-200/60 shadow-sm overflow-hidden flex flex-col"> |
| 141 | + <div className="min-h-[60px] px-6 border-b border-gray-100 flex justify-between items-center bg-white"> |
| 142 | + <div> |
| 143 | + <h3 className="font-bold text-gray-900 text-lg flex items-center gap-2"> |
| 144 | + {selectedObject} |
| 145 | + <span className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 text-xs font-medium">{data.length} records</span> |
| 146 | + </h3> |
| 147 | + </div> |
| 148 | + <div className="flex gap-2"> |
| 149 | + <Button onClick={fetchData} variant="secondary" className="h-8 text-xs px-3 shadow-none border-gray-200"> |
| 150 | + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1.5"><path d="M23 4v6h-6"></path><path d="M1 20v-6h6"></path><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg> |
| 151 | + Refresh |
| 152 | + </Button> |
| 153 | + <Button variant="secondary" className="h-8 text-xs px-3 shadow-none border-gray-200"> |
| 154 | + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1.5"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg> |
| 155 | + Filter |
| 156 | + </Button> |
| 157 | + <Button className="h-8 text-xs px-3 shadow-none bg-black hover:bg-gray-800"> |
| 158 | + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-1.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> |
| 159 | + New Record |
| 160 | + </Button> |
| 161 | + </div> |
| 162 | + </div> |
| 163 | + |
| 164 | + <div className="flex-1 overflow-auto bg-white relative"> |
| 165 | + {loading && ( |
| 166 | + <div className="absolute inset-0 bg-white/50 backdrop-blur-sm z-10 flex items-center justify-center"> |
| 167 | + <Spinner className="w-6 h-6 text-blue-500" /> |
| 168 | + </div> |
| 169 | + )} |
| 170 | + |
| 171 | + {error ? ( |
| 172 | + <div className="flex flex-col items-center justify-center h-full text-red-500 p-8 text-center"> |
| 173 | + <Icons.Layout className="w-8 h-8 mb-2 opacity-50" /> |
| 174 | + <p className="font-medium">Error loading data</p> |
| 175 | + <p className="text-sm opacity-75 max-w-md break-words">{error}</p> |
| 176 | + <Button onClick={fetchData} variant="secondary" className="mt-4">Try Again</Button> |
| 177 | + </div> |
| 178 | + ) : data.length === 0 ? ( |
| 179 | + <div className="flex flex-col items-center justify-center h-full text-gray-300 p-8"> |
| 180 | + <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="mb-4 opacity-50"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg> |
| 181 | + <p className="text-sm font-medium text-gray-400">No records found for {selectedObject}</p> |
| 182 | + </div> |
| 183 | + ) : ( |
| 184 | + <table className="w-full text-left border-collapse text-sm"> |
| 185 | + <thead className="bg-gray-50/80 sticky top-0 z-10 backdrop-blur-sm"> |
| 186 | + <tr> |
| 187 | + {Object.keys(data[0] || {}).map(key => ( |
| 188 | + <th key={key} className="px-6 py-3 font-semibold text-gray-500 border-b border-gray-100 whitespace-nowrap text-xs uppercase tracking-wider"> |
| 189 | + {key} |
| 190 | + </th> |
| 191 | + ))} |
| 192 | + </tr> |
| 193 | + </thead> |
| 194 | + <tbody className="divide-y divide-gray-100"> |
| 195 | + {data.map((row, idx) => ( |
| 196 | + <tr key={idx} className="hover:bg-blue-50/30 transition-colors group cursor-default"> |
| 197 | + {Object.keys(data[0] || {}).map(key => ( |
| 198 | + <td key={key} className="px-6 py-3.5 text-gray-700 whitespace-nowrap max-w-xs overflow-hidden text-ellipsis font-normal group-hover:text-gray-900"> |
| 199 | + {typeof row[key] === 'object' ? |
| 200 | + <span className="font-mono text-xs text-gray-400">{JSON.stringify(row[key])}</span> : |
| 201 | + String(row[key]) |
| 202 | + } |
| 203 | + </td> |
| 204 | + ))} |
| 205 | + </tr> |
| 206 | + ))} |
| 207 | + </tbody> |
| 208 | + </table> |
| 209 | + )} |
| 210 | + </div> |
| 211 | + </div> |
| 212 | + </div> |
| 213 | + ); |
| 214 | + } |
| 215 | + |
38 | 216 | function DashboardApp() { |
39 | 217 | const [user, setUser] = useState(null); |
40 | 218 | const [loading, setLoading] = useState(true); |
|
154 | 332 | )} |
155 | 333 |
|
156 | 334 | {activeTab === 'data' && ( |
157 | | - <Card title="Data Explorer" description="Browse database objects and schemas directly."> |
158 | | - <div className="p-12 text-center text-gray-400"> |
159 | | - <svg className="w-16 h-16 mx-auto mb-4 opacity-20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path></svg> |
160 | | - <p>Select an object to inspect data.</p> |
161 | | - </div> |
162 | | - </Card> |
| 335 | + <DataExplorer user={user} /> |
163 | 336 | )} |
164 | 337 |
|
165 | 338 | </div> |
|
0 commit comments