index.html 54 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892
  1. <!DOCTYPE html>
  2. <html lang="en" class="dark">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>Raft KV Admin</title>
  7. <script src="https://cdn.tailwindcss.com"></script>
  8. <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
  9. <script src="https://cdnjs.cloudflare.com/ajax/libs/qrious/4.0.2/qrious.min.js"></script>
  10. <script>
  11. tailwind.config = {
  12. darkMode: 'class',
  13. theme: {
  14. extend: {
  15. colors: {
  16. dark: {
  17. 900: '#0f172a',
  18. 800: '#1e293b',
  19. 700: '#334155',
  20. },
  21. brand: {
  22. 500: '#3b82f6',
  23. 600: '#2563eb',
  24. }
  25. }
  26. }
  27. }
  28. }
  29. </script>
  30. <style>
  31. [x-cloak] { display: none !important; }
  32. </style>
  33. </head>
  34. <body class="bg-dark-900 text-slate-300 font-sans antialiased" x-data="app()">
  35. <!-- Login Modal -->
  36. <div x-show="!isLoggedIn" x-transition.opacity class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
  37. <div class="bg-dark-800 p-8 rounded-2xl shadow-2xl w-full max-w-md border border-dark-700">
  38. <h2 class="text-2xl font-bold text-white mb-6 text-center">Raft KV Admin</h2>
  39. <form @submit.prevent="login">
  40. <div class="mb-4">
  41. <label class="block text-sm font-medium mb-2">Username</label>
  42. <input type="text" x-model="loginForm.user" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white focus:ring-2 focus:ring-brand-500 outline-none">
  43. </div>
  44. <div class="mb-4">
  45. <label class="block text-sm font-medium mb-2">Password</label>
  46. <input type="password" x-model="loginForm.pass" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white focus:ring-2 focus:ring-brand-500 outline-none">
  47. </div>
  48. <div class="mb-6">
  49. <label class="block text-sm font-medium mb-2">OTP (Optional)</label>
  50. <input type="text" x-model="loginForm.otp" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white focus:ring-2 focus:ring-brand-500 outline-none">
  51. </div>
  52. <button type="submit" class="w-full bg-brand-600 hover:bg-brand-500 text-white font-bold py-3 rounded-lg transition shadow-lg shadow-brand-500/20">
  53. <span x-show="!loading">Login</span>
  54. <span x-show="loading">Authenticating...</span>
  55. </button>
  56. <p x-show="errorMsg" x-text="errorMsg" class="mt-4 text-red-400 text-center text-sm"></p>
  57. </form>
  58. </div>
  59. </div>
  60. <!-- Main Layout -->
  61. <div x-show="isLoggedIn" x-cloak class="flex h-screen overflow-hidden">
  62. <!-- Sidebar -->
  63. <aside class="w-64 bg-dark-800 border-r border-dark-700 flex flex-col">
  64. <div class="p-6 flex items-center gap-3">
  65. <div class="w-8 h-8 rounded bg-brand-500 flex items-center justify-center font-bold text-white">R</div>
  66. <span class="text-xl font-bold text-white tracking-tight">Raft Admin</span>
  67. </div>
  68. <nav class="flex-1 px-4 space-y-2 mt-4">
  69. <a @click="tab = 'dashboard'" :class="{'bg-brand-600 text-white shadow-lg shadow-brand-500/20': tab === 'dashboard', 'hover:bg-dark-700': tab !== 'dashboard'}" class="flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer transition-all">
  70. <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
  71. Dashboard
  72. </a>
  73. <a @click="tab = 'data'" :class="{'bg-brand-600 text-white shadow-lg shadow-brand-500/20': tab === 'data', 'hover:bg-dark-700': tab !== 'data'}" class="flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer transition-all">
  74. <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg>
  75. KV Explorer
  76. </a>
  77. <div class="pt-4 pb-2 px-2 text-xs font-bold text-slate-500 uppercase">Access Control</div>
  78. <a @click="fetchUsers(); tab = 'users'" :class="{'bg-brand-600 text-white shadow-lg shadow-brand-500/20': tab === 'users', 'hover:bg-dark-700': tab !== 'users'}" class="flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer transition-all">
  79. <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path></svg>
  80. Users
  81. </a>
  82. <a @click="fetchRoles(); tab = 'roles'" :class="{'bg-brand-600 text-white shadow-lg shadow-brand-500/20': tab === 'roles', 'hover:bg-dark-700': tab !== 'roles'}" class="flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer transition-all">
  83. <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
  84. Roles
  85. </a>
  86. <div class="pt-4 pb-2 px-2 text-xs font-bold text-slate-500 uppercase">Account</div>
  87. <a @click="checkMFA(); tab = 'settings'" :class="{'bg-brand-600 text-white shadow-lg shadow-brand-500/20': tab === 'settings', 'hover:bg-dark-700': tab !== 'settings'}" class="flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer transition-all">
  88. <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
  89. Settings
  90. </a>
  91. </nav>
  92. <div class="p-4 border-t border-dark-700">
  93. <button @click="logout" class="flex items-center gap-3 w-full px-4 py-2 text-sm text-slate-400 hover:text-white transition-colors">
  94. <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
  95. Logout
  96. </button>
  97. </div>
  98. </aside>
  99. <!-- Main Content -->
  100. <main class="flex-1 flex flex-col overflow-hidden bg-dark-900">
  101. <!-- Topbar -->
  102. <header class="h-16 bg-dark-800/50 backdrop-blur border-b border-dark-700 flex items-center justify-between px-8">
  103. <h2 class="text-xl font-semibold text-white capitalize" x-text="tab"></h2>
  104. <div class="flex items-center gap-4">
  105. <span class="text-sm text-slate-400">Connected as <span class="text-white font-medium" x-text="username"></span></span>
  106. <div class="w-8 h-8 rounded-full bg-gradient-to-r from-brand-500 to-purple-500"></div>
  107. </div>
  108. </header>
  109. <!-- Scrollable Area -->
  110. <div class="flex-1 overflow-y-auto p-8">
  111. <!-- Dashboard Tab -->
  112. <div x-show="tab === 'dashboard'" class="space-y-6">
  113. <!-- System Info Grid -->
  114. <template x-if="info">
  115. <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
  116. <!-- Node Status -->
  117. <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700">
  118. <h4 class="text-slate-400 text-sm font-bold uppercase mb-4">Node Status</h4>
  119. <div class="space-y-2">
  120. <div class="flex justify-between">
  121. <span class="text-slate-500">ID</span>
  122. <span class="text-white font-mono" x-text="info.node.id"></span>
  123. </div>
  124. <div class="flex justify-between">
  125. <span class="text-slate-500">State</span>
  126. <span class="font-bold" :class="info.node.state === 'Leader' ? 'text-yellow-400' : 'text-blue-400'" x-text="info.node.state"></span>
  127. </div>
  128. <div class="flex justify-between">
  129. <span class="text-slate-500">Term</span>
  130. <span class="text-white font-mono" x-text="info.node.term"></span>
  131. </div>
  132. <div class="flex justify-between">
  133. <span class="text-slate-500">Health</span>
  134. <span class="" :class="info.node.healthy ? 'text-green-400' : 'text-red-400'" x-text="info.node.healthy ? 'Healthy' : 'Unhealthy'"></span>
  135. </div>
  136. </div>
  137. </div>
  138. <!-- Storage -->
  139. <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700">
  140. <h4 class="text-slate-400 text-sm font-bold uppercase mb-4">Storage</h4>
  141. <div class="space-y-2">
  142. <div class="flex justify-between">
  143. <span class="text-slate-500">DB Size</span>
  144. <span class="text-white font-mono" x-text="(info.storage.db_size / 1024).toFixed(2) + ' KB'"></span>
  145. </div>
  146. <div class="flex justify-between">
  147. <span class="text-slate-500">Log Size</span>
  148. <span class="text-white font-mono" x-text="(info.storage.log_size / 1024).toFixed(2) + ' KB'"></span>
  149. </div>
  150. <div class="flex justify-between">
  151. <span class="text-slate-500">Mem Alloc</span>
  152. <span class="text-white font-mono" x-text="(info.storage.mem_alloc / 1024 / 1024).toFixed(2) + ' MB'"></span>
  153. </div>
  154. <div class="flex justify-between">
  155. <span class="text-slate-500">GC Count</span>
  156. <span class="text-white font-mono" x-text="info.storage.num_gc"></span>
  157. </div>
  158. </div>
  159. </div>
  160. <!-- Consensus -->
  161. <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700">
  162. <h4 class="text-slate-400 text-sm font-bold uppercase mb-4">Consensus</h4>
  163. <div class="space-y-2">
  164. <div class="flex justify-between">
  165. <span class="text-slate-500">Commit Idx</span>
  166. <span class="text-white font-mono" x-text="info.indices.commit_index"></span>
  167. </div>
  168. <div class="flex justify-between">
  169. <span class="text-slate-500">Applied Idx</span>
  170. <span class="text-white font-mono" x-text="info.indices.applied_index"></span>
  171. </div>
  172. <div class="flex justify-between">
  173. <span class="text-slate-500">Last Log</span>
  174. <span class="text-white font-mono" x-text="info.indices.last_log_index"></span>
  175. </div>
  176. <div class="flex justify-between">
  177. <span class="text-slate-500">DB Applied</span>
  178. <span class="text-white font-mono" x-text="info.indices.db_applied"></span>
  179. </div>
  180. </div>
  181. </div>
  182. <!-- Cluster -->
  183. <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700 overflow-hidden">
  184. <h4 class="text-slate-400 text-sm font-bold uppercase mb-4">Cluster (<span x-text="info.cluster_size"></span>)</h4>
  185. <div class="space-y-2 max-h-32 overflow-y-auto">
  186. <template x-for="(addr, id) in info.cluster" :key="id">
  187. <div class="flex justify-between items-center text-sm">
  188. <div class="flex items-center gap-2">
  189. <div class="w-2 h-2 rounded-full" :class="id === info.node.leader ? 'bg-yellow-400' : 'bg-green-400'"></div>
  190. <span class="text-white font-mono" x-text="id"></span>
  191. </div>
  192. <span class="text-slate-500 text-xs" x-text="addr"></span>
  193. </div>
  194. </template>
  195. </div>
  196. </div>
  197. </div>
  198. </template>
  199. <!-- Loading State -->
  200. <template x-if="!info">
  201. <div class="text-center p-12 bg-dark-800 rounded-2xl border border-dark-700 animate-pulse">
  202. <div class="text-slate-500">Loading Node Info...</div>
  203. </div>
  204. </template>
  205. <div class="bg-dark-800 rounded-2xl border border-dark-700 p-6">
  206. <h3 class="text-lg font-bold text-white mb-4">Web CLI</h3>
  207. <div class="mb-4 text-sm text-slate-400 bg-dark-900/50 p-3 rounded-lg border border-dark-700 font-mono leading-relaxed max-h-40 overflow-y-auto">
  208. <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2">
  209. <div>
  210. <div class="text-xs font-bold text-slate-500 mb-1 uppercase">KV Data</div>
  211. <div class="space-y-1">
  212. <div><span class="text-brand-500">GET</span> &lt;key&gt;</div>
  213. <div><span class="text-brand-500">SET</span> &lt;key&gt; &lt;val&gt;</div>
  214. <div><span class="text-brand-500">DEL</span> &lt;key&gt;</div>
  215. <div><span class="text-brand-500">SEARCH</span> &lt;pat&gt; [lim]</div>
  216. <div><span class="text-brand-500">COUNT</span> &lt;pat&gt;</div>
  217. </div>
  218. </div>
  219. <div>
  220. <div class="text-xs font-bold text-slate-500 mb-1 uppercase">Cluster</div>
  221. <div class="space-y-1">
  222. <div><span class="text-brand-500">INFO</span></div>
  223. <div><span class="text-brand-500">JOIN</span> &lt;id&gt; &lt;addr&gt;</div>
  224. <div><span class="text-brand-500">LEAVE</span> &lt;id&gt;</div>
  225. </div>
  226. </div>
  227. <div class="md:col-span-2 mt-2">
  228. <div class="text-xs font-bold text-slate-500 mb-1 uppercase">User & Role Management</div>
  229. <div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-1">
  230. <div><span class="text-brand-500">USER_LIST</span></div>
  231. <div><span class="text-brand-500">ROLE_LIST</span></div>
  232. <div><span class="text-brand-500">WHOAMI</span></div>
  233. <div><span class="text-brand-500">USER_CREATE</span> &lt;u&gt; &lt;p&gt; [roles]</div>
  234. <div><span class="text-brand-500">ROLE_CREATE</span> &lt;name&gt;</div>
  235. <div><span class="text-brand-500">USER_UNLOCK</span> &lt;u&gt;</div>
  236. <div class="md:col-span-2"><span class="text-brand-500">ROLE_PERMISSION_ADD</span> &lt;role&gt; &lt;pat&gt; &lt;acts&gt;</div>
  237. </div>
  238. </div>
  239. </div>
  240. </div>
  241. <div class="flex gap-2">
  242. <input type="text" x-model="quickCmd" @keyup.enter="runQuickCmd" placeholder="Type command (e.g. HELP)..." class="flex-1 bg-dark-900 border border-dark-700 rounded-lg p-3 text-white outline-none focus:border-brand-500 font-mono">
  243. <button @click="runQuickCmd" class="bg-brand-600 px-6 rounded-lg text-white font-bold hover:bg-brand-500">Run</button>
  244. </div>
  245. <div x-show="quickResult" class="mt-4 bg-black/50 p-4 rounded-lg border border-dark-700 overflow-x-auto">
  246. <pre class="text-sm text-green-400 font-mono whitespace-pre-wrap" x-text="quickResult"></pre>
  247. </div>
  248. </div>
  249. </div>
  250. <!-- KV Explorer Tab -->
  251. <div x-show="tab === 'data'" class="space-y-6">
  252. <div class="bg-dark-800 rounded-2xl border border-dark-700 overflow-hidden">
  253. <div class="p-6 border-b border-dark-700 flex justify-between items-center">
  254. <h3 class="text-lg font-bold text-white">Key-Value Operations</h3>
  255. </div>
  256. <div class="p-6 space-y-4">
  257. <!-- Operation Form -->
  258. <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
  259. <select x-model="kvOp" class="bg-dark-900 border border-dark-700 rounded-lg p-3 text-white">
  260. <option value="GET">GET</option>
  261. <option value="SET">SET</option>
  262. <option value="DEL">DEL</option>
  263. </select>
  264. <input x-model="kvKey" type="text" placeholder="Key" class="bg-dark-900 border border-dark-700 rounded-lg p-3 text-white">
  265. <input x-show="kvOp === 'SET'" x-model="kvVal" type="text" placeholder="Value" class="bg-dark-900 border border-dark-700 rounded-lg p-3 text-white md:col-span-1">
  266. <button @click="executeKV" class="bg-brand-600 text-white rounded-lg p-3 font-bold hover:bg-brand-500">Execute</button>
  267. </div>
  268. <!-- Result Display -->
  269. <div x-show="kvResult" class="mt-4 p-4 rounded-lg border border-dark-700" :class="kvError ? 'bg-red-900/20 border-red-800 text-red-200' : 'bg-green-900/20 border-green-800 text-green-200'">
  270. <span class="font-bold block mb-1">Result:</span>
  271. <span class="font-mono" x-text="kvResult"></span>
  272. </div>
  273. </div>
  274. </div>
  275. </div>
  276. <!-- Settings Tab -->
  277. <div x-show="tab === 'settings'" class="space-y-6">
  278. <div class="bg-dark-800 rounded-2xl border border-dark-700 p-8">
  279. <h3 class="text-xl font-bold text-white mb-6">Security Settings</h3>
  280. <div class="flex items-start justify-between">
  281. <div>
  282. <div class="font-bold text-white text-lg mb-1">Two-Factor Authentication (2FA)</div>
  283. <div class="text-slate-400 max-w-lg">Protect your account by requiring a code from your authenticator app (like Google Authenticator) in addition to your password.</div>
  284. </div>
  285. <div>
  286. <template x-if="mfaStatus === 'enabled'">
  287. <button @click="disableMFA" class="bg-red-500/10 text-red-400 border border-red-500/50 px-6 py-2 rounded-lg font-bold hover:bg-red-500/20 transition">Disable 2FA</button>
  288. </template>
  289. <template x-if="mfaStatus !== 'enabled'">
  290. <button @click="startMFASetup" class="bg-brand-600 text-white px-6 py-2 rounded-lg font-bold hover:bg-brand-500 transition">Enable 2FA</button>
  291. </template>
  292. </div>
  293. </div>
  294. <!-- MFA Setup Area -->
  295. <div x-show="isSettingUpMFA" class="mt-8 border-t border-dark-700 pt-8" x-transition>
  296. <h4 class="text-lg font-bold text-white mb-4">Setup 2FA</h4>
  297. <div class="flex flex-col md:flex-row gap-8">
  298. <div class="bg-white p-4 rounded-lg inline-block">
  299. <canvas id="qr" width="200" height="200"></canvas>
  300. </div>
  301. <div class="flex-1 space-y-4">
  302. <div class="p-4 bg-dark-900 rounded-lg border border-dark-700">
  303. <div class="text-xs text-slate-500 uppercase font-bold mb-1">Secret Key</div>
  304. <div class="font-mono text-xl text-white tracking-wider" x-text="mfaSecret"></div>
  305. </div>
  306. <div>
  307. <label class="block text-sm font-medium mb-2 text-slate-400">Verification Code</label>
  308. <div class="flex gap-2">
  309. <input x-model="mfaCode" type="text" placeholder="000000" maxlength="6" class="w-40 bg-dark-900 border border-dark-700 rounded-lg p-3 text-white text-center font-mono text-lg outline-none focus:border-brand-500">
  310. <button @click="verifyAndEnableMFA" class="bg-green-600 text-white px-6 rounded-lg font-bold hover:bg-green-500">Verify & Enable</button>
  311. </div>
  312. </div>
  313. <p class="text-sm text-slate-500">Scan the QR code with your authenticator app, or enter the secret key manually. Then enter the 6-digit code to confirm.</p>
  314. </div>
  315. </div>
  316. </div>
  317. </div>
  318. </div>
  319. <!-- Users Tab -->
  320. <div x-show="tab === 'users'" class="space-y-6">
  321. <!-- Actions -->
  322. <div class="flex justify-end">
  323. <button @click="showAddUserModal = true" class="bg-brand-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-brand-500 flex items-center gap-2">
  324. <span>+ Add User</span>
  325. </button>
  326. </div>
  327. <!-- User Table -->
  328. <div class="bg-dark-800 rounded-2xl border border-dark-700 overflow-hidden">
  329. <table class="w-full text-left">
  330. <thead class="bg-dark-700 text-slate-300">
  331. <tr>
  332. <th class="p-4">Username</th>
  333. <th class="p-4">Roles</th>
  334. <th class="p-4">Status</th>
  335. <th class="p-4 text-right">Actions</th>
  336. </tr>
  337. </thead>
  338. <tbody class="divide-y divide-dark-700">
  339. <template x-for="u in users" :key="u.username">
  340. <tr class="hover:bg-dark-700/50 transition">
  341. <td class="p-4 font-medium text-white" x-text="u.username"></td>
  342. <td class="p-4">
  343. <div class="flex gap-2">
  344. <template x-for="r in (u.roles || [])">
  345. <span class="px-2 py-1 bg-blue-500/20 text-blue-300 rounded text-xs" x-text="r"></span>
  346. </template>
  347. </div>
  348. </td>
  349. <td class="p-4">
  350. <span class="inline-flex items-center gap-1 text-green-400 text-sm">
  351. <span class="w-2 h-2 rounded-full bg-green-400"></span> Active
  352. </span>
  353. </td>
  354. <td class="p-4 text-right">
  355. <button @click="openEditUserModal(u)" class="text-blue-500 hover:text-blue-400 text-sm mr-3">Edit</button>
  356. <button @click="unlockUser(u.username)" class="text-yellow-500 hover:text-yellow-400 text-sm mr-3">Reset Lock</button>
  357. <!-- <button class="text-red-500 hover:text-red-400 text-sm">Delete</button> -->
  358. </td>
  359. </tr>
  360. </template>
  361. </tbody>
  362. </table>
  363. </div>
  364. </div>
  365. <!-- Roles Tab -->
  366. <div x-show="tab === 'roles'" class="space-y-6">
  367. <div class="flex justify-end">
  368. <button @click="showAddRoleModal = true" class="bg-brand-600 text-white px-4 py-2 rounded-lg font-bold hover:bg-brand-500">
  369. + Create Role
  370. </button>
  371. </div>
  372. <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  373. <template x-for="r in roles" :key="r.name">
  374. <div class="bg-dark-800 p-6 rounded-2xl border border-dark-700 hover:border-brand-500 transition group">
  375. <div class="flex justify-between items-start mb-4">
  376. <h3 class="text-lg font-bold text-white" x-text="r.name"></h3>
  377. <span class="text-xs text-slate-500 bg-dark-900 px-2 py-1 rounded" x-text="(r.permissions || []).length + ' Rules'"></span>
  378. </div>
  379. <div class="space-y-2 mb-4 max-h-60 overflow-y-auto pr-1 custom-scrollbar">
  380. <template x-for="p in (r.permissions || [])">
  381. <div class="text-sm text-slate-400 font-mono bg-dark-900/50 p-2 rounded flex justify-between items-center group/perm hover:bg-dark-900 transition">
  382. <div>
  383. <span x-text="p.key" class="font-bold text-slate-300"></span>
  384. <span class="text-brand-500 text-xs ml-2" x-text="'[' + p.actions.join(',') + ']'"></span>
  385. <template x-if="p.constraint">
  386. <span class="text-yellow-500 text-xs ml-2" x-text="'Range: ' + (p.constraint.min !== null ? p.constraint.min : '-∞') + ' ~ ' + (p.constraint.max !== null ? p.constraint.max : '+∞')"></span>
  387. </template>
  388. </div>
  389. <div class="flex gap-2 opacity-0 group-hover/perm:opacity-100 transition-opacity">
  390. <button @click="openAddPermModal(r.name, p)" class="text-blue-400 hover:text-blue-300" title="Edit">
  391. <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path></svg>
  392. </button>
  393. <button @click="deletePermission(r.name, p.key)" class="text-red-400 hover:text-red-300" title="Delete">
  394. <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
  395. </button>
  396. </div>
  397. </div>
  398. </template>
  399. <div x-show="(r.permissions || []).length === 0" class="text-xs text-slate-500 italic p-2 text-center">No rules defined</div>
  400. </div>
  401. <button @click="openAddPermModal(r.name)" class="w-full py-2 bg-dark-700 hover:bg-dark-600 text-sm text-white rounded-lg transition">
  402. Add Permission
  403. </button>
  404. </div>
  405. </template>
  406. </div>
  407. </div>
  408. </div>
  409. </main>
  410. </div>
  411. <!-- Modals -->
  412. <!-- Add User Modal -->
  413. <div x-show="showAddUserModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" style="display: none;">
  414. <div class="bg-dark-800 p-6 rounded-2xl w-full max-w-md border border-dark-700">
  415. <h3 class="text-xl font-bold text-white mb-4">Create New User</h3>
  416. <div class="space-y-4">
  417. <input x-model="newUser.username" type="text" placeholder="Username" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white">
  418. <input x-model="newUser.password" type="password" placeholder="Password" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white">
  419. <input x-model="newUser.roles" type="text" placeholder="Roles (comma separated)" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white">
  420. <div class="flex gap-3 pt-2">
  421. <button @click="createUser" class="flex-1 bg-brand-600 text-white py-2 rounded-lg font-bold hover:bg-brand-500">Create</button>
  422. <button @click="showAddUserModal = false" class="flex-1 bg-dark-700 text-white py-2 rounded-lg font-bold hover:bg-dark-600">Cancel</button>
  423. </div>
  424. </div>
  425. </div>
  426. </div>
  427. <!-- Edit User Modal -->
  428. <div x-show="showEditUserModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" style="display: none;">
  429. <div class="bg-dark-800 p-6 rounded-2xl w-full max-w-md border border-dark-700">
  430. <h3 class="text-xl font-bold text-white mb-4">Edit User <span x-text="editUserForm.username" class="text-brand-500"></span></h3>
  431. <div class="space-y-4">
  432. <div>
  433. <label class="block text-sm font-medium mb-1 text-slate-400">New Password (Optional)</label>
  434. <input x-model="editUserForm.password" type="password" placeholder="Leave empty to keep current" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white">
  435. </div>
  436. <div>
  437. <label class="block text-sm font-medium mb-1 text-slate-400">Roles</label>
  438. <input x-model="editUserForm.roles" type="text" placeholder="Roles (comma separated)" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white">
  439. </div>
  440. <div class="flex gap-3 pt-2">
  441. <button @click="updateUser" class="flex-1 bg-brand-600 text-white py-2 rounded-lg font-bold hover:bg-brand-500">Save Changes</button>
  442. <button @click="showEditUserModal = false" class="flex-1 bg-dark-700 text-white py-2 rounded-lg font-bold hover:bg-dark-600">Cancel</button>
  443. </div>
  444. </div>
  445. </div>
  446. </div>
  447. <!-- Add Role Modal -->
  448. <div x-show="showAddRoleModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" style="display: none;">
  449. <div class="bg-dark-800 p-6 rounded-2xl w-full max-w-md border border-dark-700">
  450. <h3 class="text-xl font-bold text-white mb-4">Create New Role</h3>
  451. <div class="space-y-4">
  452. <input x-model="newRoleName" type="text" placeholder="Role Name" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white">
  453. <div class="flex gap-3 pt-2">
  454. <button @click="createRole" class="flex-1 bg-brand-600 text-white py-2 rounded-lg font-bold hover:bg-brand-500">Create</button>
  455. <button @click="showAddRoleModal = false" class="flex-1 bg-dark-700 text-white py-2 rounded-lg font-bold hover:bg-dark-600">Cancel</button>
  456. </div>
  457. </div>
  458. </div>
  459. </div>
  460. <!-- Add Permission Modal -->
  461. <div x-show="showAddPermModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" style="display: none;">
  462. <div class="bg-dark-800 p-6 rounded-2xl w-full max-w-md border border-dark-700">
  463. <h3 class="text-xl font-bold text-white mb-4"><span x-text="isEditing ? 'Edit' : 'Add'"></span> Permission for <span x-text="selectedRole" class="text-brand-500"></span></h3>
  464. <div class="space-y-4">
  465. <div>
  466. <label class="block text-sm font-medium mb-2 text-slate-400">Key Pattern</label>
  467. <input x-model="newPerm.pattern" :disabled="isEditing" :class="{'opacity-50 cursor-not-allowed': isEditing}" type="text" placeholder="e.g. user.* or *" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white outline-none focus:border-brand-500">
  468. </div>
  469. <div>
  470. <label class="block text-sm font-medium mb-2 text-slate-400">Actions</label>
  471. <div class="flex gap-4 p-3 bg-dark-900 rounded-lg border border-dark-700">
  472. <label class="flex items-center gap-2 text-white cursor-pointer">
  473. <input type="checkbox" value="read" x-model="newPerm.actions" class="accent-brand-500"> Read
  474. </label>
  475. <label class="flex items-center gap-2 text-white cursor-pointer">
  476. <input type="checkbox" value="write" x-model="newPerm.actions" class="accent-brand-500"> Write
  477. </label>
  478. <label class="flex items-center gap-2 text-white cursor-pointer">
  479. <input type="checkbox" value="admin" x-model="newPerm.actions" class="accent-brand-500"> Admin
  480. </label>
  481. </div>
  482. </div>
  483. <div x-show="newPerm.actions.includes('write')" x-transition class="grid grid-cols-2 gap-4">
  484. <div>
  485. <label class="block text-sm font-medium mb-2 text-slate-400">Min Value (Optional)</label>
  486. <input x-model="newPerm.min" type="number" step="any" placeholder="No Limit" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white outline-none focus:border-brand-500">
  487. </div>
  488. <div>
  489. <label class="block text-sm font-medium mb-2 text-slate-400">Max Value (Optional)</label>
  490. <input x-model="newPerm.max" type="number" step="any" placeholder="No Limit" class="w-full bg-dark-900 border border-dark-700 rounded-lg p-3 text-white outline-none focus:border-brand-500">
  491. </div>
  492. </div>
  493. <div class="flex gap-3 pt-2">
  494. <button @click="addPermission" class="flex-1 bg-brand-600 text-white py-2 rounded-lg font-bold hover:bg-brand-500" x-text="isEditing ? 'Save' : 'Add'"></button>
  495. <button @click="showAddPermModal = false" class="flex-1 bg-dark-700 text-white py-2 rounded-lg font-bold hover:bg-dark-600">Cancel</button>
  496. </div>
  497. </div>
  498. </div>
  499. </div>
  500. <script>
  501. function app() {
  502. return {
  503. isLoggedIn: false,
  504. token: localStorage.getItem('raft_token') || '',
  505. username: localStorage.getItem('raft_user') || '',
  506. tab: 'dashboard',
  507. loading: false,
  508. errorMsg: '',
  509. // Login Form
  510. loginForm: { user: '', pass: '', otp: '' },
  511. // Data
  512. users: [],
  513. roles: [],
  514. kvHistory: [], // Track KV ops
  515. info: null, // System Info
  516. // KV Op
  517. kvOp: 'GET',
  518. kvKey: '',
  519. kvVal: '',
  520. kvResult: '',
  521. kvError: false,
  522. // Quick Command
  523. quickCmd: '',
  524. quickResult: '',
  525. // Modals
  526. showAddUserModal: false,
  527. newUser: { username: '', password: '', roles: '' },
  528. showEditUserModal: false,
  529. editUserForm: { username: '', password: '', roles: '' },
  530. showAddRoleModal: false,
  531. newRoleName: '',
  532. // Add Permission Modal
  533. showAddPermModal: false,
  534. isEditing: false,
  535. selectedRole: '',
  536. newPerm: { pattern: '', actions: [], min: '', max: '' },
  537. // MFA
  538. mfaStatus: 'unknown',
  539. isSettingUpMFA: false,
  540. mfaSecret: '',
  541. mfaCode: '',
  542. init() {
  543. if (this.token) {
  544. this.isLoggedIn = true;
  545. this.fetchUsers();
  546. this.fetchInfo();
  547. // Refresh info periodically
  548. setInterval(() => {
  549. if (this.isLoggedIn && this.tab === 'dashboard') {
  550. this.fetchInfo();
  551. }
  552. }, 2000);
  553. }
  554. },
  555. async api(command, args = []) {
  556. try {
  557. const res = await fetch('/api/proxy', {
  558. method: 'POST',
  559. headers: {
  560. 'Content-Type': 'application/json',
  561. 'X-Session-Token': this.token
  562. },
  563. body: JSON.stringify({ command, args })
  564. });
  565. const json = await res.json();
  566. if (json.status === 'error') {
  567. if (json.error === 'Session Expired' || json.error.startsWith('Connection failed')) {
  568. this.logout();
  569. }
  570. throw new Error(json.error);
  571. }
  572. return json.data;
  573. } catch (e) {
  574. throw e;
  575. }
  576. },
  577. async login() {
  578. this.loading = true;
  579. this.errorMsg = '';
  580. try {
  581. // Args: user pass [otp]
  582. const args = [this.loginForm.user, this.loginForm.pass];
  583. if (this.loginForm.otp) args.push(this.loginForm.otp);
  584. const token = await this.api('LOGIN', args);
  585. // If success, token is returned string
  586. // Note: My TCP API returns "OK <token>" string or JSON if modified.
  587. // The proxy handles "OK " stripping. So 'token' variable here holds the raw token string.
  588. this.token = token.trim();
  589. this.username = this.loginForm.user;
  590. localStorage.setItem('raft_token', this.token);
  591. localStorage.setItem('raft_user', this.username);
  592. this.isLoggedIn = true;
  593. this.fetchUsers();
  594. } catch (e) {
  595. this.errorMsg = e.message;
  596. } finally {
  597. this.loading = false;
  598. }
  599. },
  600. logout() {
  601. this.api('LOGOUT');
  602. this.token = '';
  603. this.username = '';
  604. this.isLoggedIn = false;
  605. localStorage.removeItem('raft_token');
  606. },
  607. async fetchUsers() {
  608. try {
  609. this.users = await this.api('USER_LIST');
  610. if (typeof this.users === 'string') {
  611. this.users = JSON.parse(this.users);
  612. }
  613. } catch (e) {
  614. console.error("Failed to fetch users", e);
  615. }
  616. },
  617. async fetchInfo() {
  618. try {
  619. const res = await this.api('INFO');
  620. // TCP returns "OK {json}" or just {json} via proxy parsing?
  621. // api() helper returns json.data.
  622. // If TCP returns "OK <json>", proxy parses it if it looks like JSON.
  623. // Our INFO returns "OK <json>".
  624. this.info = res;
  625. if (typeof this.info === 'string') {
  626. try {
  627. this.info = JSON.parse(this.info);
  628. } catch(e) { /* ignore */ }
  629. }
  630. } catch (e) {
  631. console.error("Failed to fetch info", e);
  632. }
  633. },
  634. async fetchRoles() {
  635. try {
  636. this.roles = await this.api('ROLE_LIST');
  637. } catch (e) {
  638. console.error(e);
  639. if (e.message.includes("unknown command")) {
  640. alert("Server error: ROLE_LIST command not found. Please restart your Raft node to apply latest changes.");
  641. }
  642. }
  643. },
  644. async executeKV() {
  645. this.kvResult = 'Processing...';
  646. this.kvError = false;
  647. try {
  648. const args = [this.kvKey];
  649. if (this.kvOp === 'SET') args.push(this.kvVal);
  650. const res = await this.api(this.kvOp, args);
  651. this.kvResult = typeof res === 'object' ? JSON.stringify(res) : res;
  652. this.kvHistory.push({ op: this.kvOp, key: this.kvKey, time: new Date().toLocaleTimeString() });
  653. } catch (e) {
  654. this.kvError = true;
  655. this.kvResult = e.message;
  656. }
  657. },
  658. async runQuickCmd() {
  659. if(!this.quickCmd) return;
  660. const parts = this.quickCmd.split(' ');
  661. const cmd = parts[0].toUpperCase();
  662. const args = parts.slice(1);
  663. try {
  664. const res = await this.api(cmd, args);
  665. this.quickResult = typeof res === 'object' ? JSON.stringify(res, null, 2) : res;
  666. } catch (e) {
  667. this.quickResult = "Error: " + e.message;
  668. }
  669. },
  670. async createUser() {
  671. try {
  672. await this.api('USER_CREATE', [this.newUser.username, this.newUser.password, this.newUser.roles]);
  673. this.showAddUserModal = false;
  674. this.newUser = { username: '', password: '', roles: '' };
  675. this.fetchUsers();
  676. } catch (e) {
  677. alert(e.message);
  678. }
  679. },
  680. openEditUserModal(user) {
  681. this.editUserForm = {
  682. username: user.username,
  683. password: '', // Clear password
  684. roles: (user.roles || []).join(',')
  685. };
  686. this.showEditUserModal = true;
  687. },
  688. async updateUser() {
  689. try {
  690. const u = this.editUserForm.username;
  691. const p = this.editUserForm.password || '-';
  692. const r = this.editUserForm.roles; // allow empty string to clear roles
  693. await this.api('USER_UPDATE', [u, p, r]);
  694. this.showEditUserModal = false;
  695. this.fetchUsers();
  696. } catch (e) {
  697. alert(e.message);
  698. }
  699. },
  700. async createRole() {
  701. try {
  702. await this.api('ROLE_CREATE', [this.newRoleName]);
  703. this.showAddRoleModal = false;
  704. this.newRoleName = '';
  705. this.fetchRoles();
  706. } catch (e) {
  707. alert(e.message);
  708. }
  709. },
  710. openAddPermModal(roleName, perm = null) {
  711. this.selectedRole = roleName;
  712. if (perm) {
  713. this.isEditing = true;
  714. this.newPerm = {
  715. pattern: perm.key,
  716. actions: [...perm.actions],
  717. min: perm.constraint && perm.constraint.min !== undefined ? perm.constraint.min : '',
  718. max: perm.constraint && perm.constraint.max !== undefined ? perm.constraint.max : ''
  719. };
  720. } else {
  721. this.isEditing = false;
  722. this.newPerm = { pattern: '', actions: [], min: '', max: '' };
  723. }
  724. this.showAddPermModal = true;
  725. },
  726. async deletePermission(roleName, pattern) {
  727. if (!confirm(`Remove permission for ${pattern} from ${roleName}?`)) return;
  728. try {
  729. await this.api('ROLE_PERMISSION_REMOVE', [roleName, pattern]);
  730. this.fetchRoles();
  731. } catch (e) {
  732. alert(e.message);
  733. }
  734. },
  735. async addPermission() {
  736. if (!this.newPerm.pattern || this.newPerm.actions.length === 0) {
  737. alert("Please provide pattern and at least one action");
  738. return;
  739. }
  740. try {
  741. // Command: ROLE_PERMISSION_ADD <role> <pattern> <actions> [min] [max]
  742. const actionsStr = this.newPerm.actions.join(',');
  743. const args = [this.selectedRole, this.newPerm.pattern, actionsStr];
  744. if (this.newPerm.actions.includes('write')) {
  745. const minVal = (this.newPerm.min !== '' && this.newPerm.min !== null) ? this.newPerm.min : '-';
  746. const maxVal = (this.newPerm.max !== '' && this.newPerm.max !== null) ? this.newPerm.max : '-';
  747. if (minVal !== '-' || maxVal !== '-') {
  748. args.push(String(minVal));
  749. args.push(String(maxVal));
  750. }
  751. }
  752. await this.api('ROLE_PERMISSION_ADD', args);
  753. this.showAddPermModal = false;
  754. this.fetchRoles();
  755. } catch (e) {
  756. alert(e.message);
  757. }
  758. },
  759. async unlockUser(username) {
  760. if(!confirm("Unlock user " + username + "?")) return;
  761. try {
  762. await this.api('USER_UNLOCK', [username]);
  763. alert("Unlocked");
  764. } catch (e) { alert(e.message); }
  765. },
  766. async checkMFA() {
  767. try {
  768. const res = await this.api('MFA-STATUS');
  769. // Expect "OK <status>" -> api() returns "<status>"
  770. this.mfaStatus = res.trim();
  771. } catch (e) {
  772. console.error("MFA Check failed", e);
  773. }
  774. },
  775. async startMFASetup() {
  776. try {
  777. // MFA-GENERATE returns "secret url"
  778. const res = await this.api('MFA-GENERATE');
  779. const parts = res.trim().split(' ');
  780. this.mfaSecret = parts[0];
  781. const url = parts[1];
  782. this.isSettingUpMFA = true;
  783. // Render QR
  784. // Wait for transition
  785. setTimeout(() => {
  786. new QRious({
  787. element: document.getElementById('qr'),
  788. value: url,
  789. size: 200
  790. });
  791. }, 100);
  792. } catch (e) {
  793. alert(e.message);
  794. }
  795. },
  796. async verifyAndEnableMFA() {
  797. if (!this.mfaCode) return alert("Enter code");
  798. try {
  799. await this.api('MFA-ENABLE', [this.mfaSecret, this.mfaCode]);
  800. alert("MFA Enabled Successfully!");
  801. this.isSettingUpMFA = false;
  802. this.mfaCode = '';
  803. this.mfaStatus = 'enabled';
  804. } catch (e) {
  805. alert(e.message);
  806. }
  807. },
  808. async disableMFA() {
  809. if (!confirm("Are you sure you want to disable 2FA?")) return;
  810. try {
  811. await this.api('MFA-DISABLE');
  812. alert("MFA Disabled");
  813. this.mfaStatus = 'disabled';
  814. } catch (e) {
  815. alert(e.message);
  816. }
  817. }
  818. }
  819. }
  820. </script>
  821. </body>
  822. </html>