DevicesView.vue 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293
  1. <template>
  2. <div class="page">
  3. <div class="page-header">
  4. <div>
  5. <h1>{{ $t('devices.title') }}</h1>
  6. <p>{{ $t('devices.manage') }}</p>
  7. </div>
  8. </div>
  9. <!-- Device Registration Toggle -->
  10. <div class="registration-banner" :class="{ enabled: autoRegistrationEnabled }">
  11. <div class="registration-content">
  12. <div class="registration-info">
  13. <h2>
  14. <span v-if="autoRegistrationEnabled">🟢 {{ $t('devices.registrationEnabled') }}</span>
  15. <span v-else>🔴 {{ $t('devices.registrationDisabled') }}</span>
  16. </h2>
  17. <p v-if="autoRegistrationEnabled" class="warning">
  18. ⚠️ {{ $t('devices.registrationWarning') }}
  19. <span v-if="registrationTimeLeft > 0">
  20. - {{ $t('devices.autoDisableIn') }} {{ formatTimeLeft(registrationTimeLeft) }}
  21. </span>
  22. </p>
  23. <p v-else>{{ $t('devices.registrationHint') }}</p>
  24. </div>
  25. <div class="registration-toggle">
  26. <label class="toggle-switch-large">
  27. <input
  28. type="checkbox"
  29. :checked="autoRegistrationEnabled"
  30. @change="toggleAutoRegistration"
  31. :disabled="togglingRegistration"
  32. />
  33. <span class="slider-large"></span>
  34. <span v-if="togglingRegistration" class="spinner">⏳</span>
  35. </label>
  36. </div>
  37. </div>
  38. </div>
  39. <div class="content">
  40. <!-- Search and Filters -->
  41. <div class="filters-row">
  42. <div class="search-box">
  43. <input
  44. v-model="searchQuery"
  45. type="text"
  46. :placeholder="$t('devices.searchPlaceholder')"
  47. class="search-input"
  48. @input="onSearch"
  49. />
  50. <span v-if="searchQuery" class="search-clear" @click="clearSearch">×</span>
  51. </div>
  52. <label class="filter-checkbox">
  53. <input type="checkbox" v-model="onlineOnly" @change="loadDevices" />
  54. <span>{{ $t('devices.onlineOnly') }}</span>
  55. </label>
  56. <button @click="showDefaultConfigModal" class="btn-primary btn-edit-default">
  57. Edit Default Config
  58. </button>
  59. </div>
  60. <div v-if="loading && devices.length === 0" class="loading">{{ $t('common.loading') }}</div>
  61. <div v-else-if="error && devices.length === 0" class="error">{{ error }}</div>
  62. <table v-else-if="devices.length > 0" class="data-table">
  63. <thead>
  64. <tr>
  65. <th @click="sortBy('simple_id')">
  66. {{ $t('devices.simpleId') }}
  67. <span v-if="sortColumn === 'simple_id'">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
  68. </th>
  69. <th @click="sortBy('mac_address')">
  70. {{ $t('devices.macAddress') }}
  71. <span v-if="sortColumn === 'mac_address'">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
  72. </th>
  73. <th>BLE Enabled</th>
  74. <th>WiFi Enabled</th>
  75. <th @click="sortBy('status')">
  76. {{ $t('common.status') }}
  77. <span v-if="sortColumn === 'status'">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
  78. </th>
  79. <th @click="sortBy('last_seen_at')">
  80. {{ $t('devices.lastSeen') }}
  81. <span v-if="sortColumn === 'last_seen_at'">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
  82. </th>
  83. <th>{{ $t('common.actions') }}</th>
  84. </tr>
  85. </thead>
  86. <tbody>
  87. <tr v-for="device in sortedDevices" :key="device.id">
  88. <td><strong>#{{ device.simple_id }}</strong></td>
  89. <td><code>{{ device.mac_address }}</code></td>
  90. <td class="text-center">
  91. <label class="toggle-switch-inline">
  92. <input
  93. type="checkbox"
  94. :checked="device.config?.ble?.enabled ?? false"
  95. @click="toggleBLE(device, $event)"
  96. />
  97. <span class="toggle-slider"></span>
  98. </label>
  99. </td>
  100. <td class="text-center">
  101. <label class="toggle-switch-inline">
  102. <input
  103. type="checkbox"
  104. :checked="device.config?.wifi?.monitor_enabled ?? false"
  105. @click="toggleWiFi(device, $event)"
  106. />
  107. <span class="toggle-slider"></span>
  108. </label>
  109. </td>
  110. <td><span class="badge" :class="`status-${device.status}`">{{ $t(`devices.${device.status}`) }}</span></td>
  111. <td>{{ formatRelativeTime(device.last_seen_at) }}</td>
  112. <td>
  113. <button @click="showEditModal(device)" class="btn-icon" title="Edit">✏️</button>
  114. <button
  115. @click="openSSH(device)"
  116. class="btn-icon"
  117. :class="{ loading: tunnelLoading[`${device.id}:ssh`] }"
  118. :disabled="tunnelLoading[`${device.id}:ssh`]"
  119. :title="tunnelLoading[`${device.id}:ssh`] ? 'Connecting...' : 'SSH Terminal'">
  120. <span v-if="tunnelLoading[`${device.id}:ssh`]" class="spinner">⏳</span>
  121. <span v-else>🖥️</span>
  122. </button>
  123. <button
  124. @click="openDashboard(device)"
  125. class="btn-icon"
  126. :class="{ loading: tunnelLoading[`${device.id}:dashboard`] }"
  127. :disabled="tunnelLoading[`${device.id}:dashboard`]"
  128. :title="tunnelLoading[`${device.id}:dashboard`] ? 'Connecting...' : 'Dashboard'">
  129. <span v-if="tunnelLoading[`${device.id}:dashboard`]" class="spinner">⏳</span>
  130. <span v-else>📊</span>
  131. </button>
  132. </td>
  133. </tr>
  134. </tbody>
  135. </table>
  136. <div v-else-if="!loading" class="empty">No devices yet</div>
  137. </div>
  138. <!-- Edit Modal -->
  139. <div v-if="modalVisible" class="modal-overlay" @click="closeModal">
  140. <div class="modal modal-wide" @click.stop>
  141. <div class="modal-header">
  142. <h2>Device #{{ editingDevice?.simple_id }} - {{ editingDevice?.mac_address }}</h2>
  143. <button @click="closeModal" class="btn-close">×</button>
  144. </div>
  145. <div class="modal-tabs">
  146. <button type="button" @click="configTab = 'interactive'" :class="['tab-button', { active: configTab === 'interactive' }]">
  147. Interactive
  148. </button>
  149. <button type="button" @click="configTab = 'json'" :class="['tab-button', { active: configTab === 'json' }]">
  150. JSON
  151. </button>
  152. </div>
  153. <form @submit.prevent="saveDevice" class="modal-body">
  154. <div v-if="configTab === 'interactive'">
  155. <!-- WiFi Scanner Section -->
  156. <div class="config-section">
  157. <h3>WiFi Scanner</h3>
  158. <div class="toggle-row">
  159. <span>{{ $t('devices.config.wifiScannerEnabled') }}</span>
  160. <label class="toggle-switch">
  161. <input type="checkbox" v-model="config.wifi.monitor_enabled" @change="onWifiMonitorChange" />
  162. <span class="toggle-slider"></span>
  163. </label>
  164. </div>
  165. <div v-if="config.wifi.monitor_enabled" class="toggle-content">
  166. <div class="form-group">
  167. <label>{{ $t('devices.config.wifiBatchInterval') }} (ms)</label>
  168. <input v-model.number="config.wifi.batch_interval_ms" type="number" min="1000" step="1000" />
  169. </div>
  170. <div class="form-group">
  171. <label>{{ $t('devices.config.uploadEndpoint') }}</label>
  172. <input v-model="config.wifi.upload_endpoint" type="text" placeholder="http://custom.example.com:8080/wifi" />
  173. <div class="form-hint">{{ $t('devices.config.uploadEndpointHint') }}</div>
  174. </div>
  175. </div>
  176. </div>
  177. <!-- BLE Section -->
  178. <div class="config-section">
  179. <h3>BLE Scanner</h3>
  180. <div class="toggle-row">
  181. <span>{{ $t('devices.config.bleScannerEnabled') }}</span>
  182. <label class="toggle-switch">
  183. <input type="checkbox" v-model="config.ble.enabled" />
  184. <span class="toggle-slider"></span>
  185. </label>
  186. </div>
  187. <div v-if="config.ble.enabled" class="toggle-content">
  188. <div class="form-group">
  189. <label>{{ $t('devices.config.bleBatchInterval') }} (ms)</label>
  190. <input v-model.number="config.ble.batch_interval_ms" type="number" min="100" step="100" />
  191. </div>
  192. <div class="form-group">
  193. <label>{{ $t('devices.config.uuidFilter') }}</label>
  194. <input v-model="config.ble.uuid_filter_hex" type="text" placeholder="f7826da64fa24e988024bc5b71e0893e" maxlength="32" />
  195. <div class="form-hint">{{ $t('devices.config.uuidFilterHint') }}</div>
  196. </div>
  197. <div class="form-group">
  198. <label>{{ $t('devices.config.uploadEndpoint') }}</label>
  199. <input v-model="config.ble.upload_endpoint" type="text" placeholder="http://custom.example.com:8080/ble" />
  200. <div class="form-hint">{{ $t('devices.config.uploadEndpointHint') }}</div>
  201. </div>
  202. </div>
  203. </div>
  204. <!-- WiFi Client Section -->
  205. <div class="config-section">
  206. <h3>WiFi Client</h3>
  207. <div class="toggle-row">
  208. <span>{{ $t('devices.config.wifiClientEnabled') }}</span>
  209. <label class="toggle-switch">
  210. <input type="checkbox" v-model="config.wifi.client_enabled" @change="onWifiClientChange" />
  211. <span class="toggle-slider"></span>
  212. </label>
  213. </div>
  214. <div v-if="config.wifi.client_enabled" class="toggle-content">
  215. <div class="form-row">
  216. <div class="form-group">
  217. <label>{{ $t('devices.config.wifiSsid') }}</label>
  218. <input v-model="config.wifi.ssid" type="text" />
  219. </div>
  220. <div class="form-group">
  221. <label>{{ $t('devices.config.wifiPassword') }}</label>
  222. <input v-model="config.wifi.psk" type="password" />
  223. </div>
  224. </div>
  225. </div>
  226. </div>
  227. <!-- NTP Section -->
  228. <div class="config-section">
  229. <h3>NTP Servers</h3>
  230. <div class="form-group">
  231. <label>{{ $t('devices.config.ntpServers') }}</label>
  232. <input v-model="ntpServersText" type="text" placeholder="pool.ntp.org, time.google.com" />
  233. <div class="form-hint">{{ $t('devices.config.ntpHint') }}</div>
  234. </div>
  235. </div>
  236. <!-- Other Settings -->
  237. <div class="config-section">
  238. <h3>Other</h3>
  239. <div class="form-group">
  240. <label>Config Polling Timeout (seconds)</label>
  241. <input v-model.number="config.cfg_polling_timeout" type="number" min="5" max="300" step="5" />
  242. <div class="form-hint">How often device fetches config from server (min: 5s, recommended: 30-300s)</div>
  243. </div>
  244. <div class="toggle-row">
  245. <span>{{ $t('devices.config.forceCloud') }}</span>
  246. <label class="toggle-switch">
  247. <input type="checkbox" v-model="config.force_cloud" />
  248. <span class="toggle-slider"></span>
  249. </label>
  250. </div>
  251. <div class="toggle-row">
  252. <span>{{ $t('devices.config.debug') }}</span>
  253. <label class="toggle-switch">
  254. <input type="checkbox" v-model="config.debug" />
  255. <span class="toggle-slider"></span>
  256. </label>
  257. </div>
  258. <div class="toggle-row">
  259. <span>{{ $t('devices.config.dashboardEnabled') }}</span>
  260. <label class="toggle-switch">
  261. <input type="checkbox" v-model="config.dashboard.enabled" />
  262. <span class="toggle-slider"></span>
  263. </label>
  264. </div>
  265. </div>
  266. </div>
  267. <div v-if="configTab === 'json'">
  268. <div class="form-group">
  269. <label>Device Config JSON (edit carefully!)</label>
  270. <textarea
  271. v-model="configJson"
  272. class="config-editor"
  273. rows="20"
  274. @input="validateConfigJson"
  275. ></textarea>
  276. <div v-if="configJsonError" class="form-error">{{ configJsonError }}</div>
  277. </div>
  278. </div>
  279. <div class="modal-footer">
  280. <button type="button" @click="deleteDevice" class="btn-danger btn-delete" :disabled="saving">
  281. Delete Device
  282. </button>
  283. <div style="flex: 1"></div>
  284. <button type="button" @click="closeModal" class="btn-secondary">{{ $t('common.cancel') }}</button>
  285. <button type="submit" :disabled="saving || (configTab === 'json' && !!configJsonError)" class="btn-primary">
  286. {{ saving ? $t('common.loading') : $t('common.save') }}
  287. </button>
  288. </div>
  289. </form>
  290. </div>
  291. </div>
  292. <!-- Edit Default Config Modal -->
  293. <div v-if="defaultConfigModalVisible" class="modal-overlay" @click="closeDefaultConfigModal">
  294. <div class="modal modal-wide" @click.stop>
  295. <div class="modal-header">
  296. <h2>Edit Default Device Configuration</h2>
  297. <button @click="closeDefaultConfigModal" class="btn-close">×</button>
  298. </div>
  299. <!-- Tabs -->
  300. <div class="modal-tabs">
  301. <button
  302. @click="defaultConfigTab = 'interactive'"
  303. :class="['tab-button', { active: defaultConfigTab === 'interactive' }]"
  304. >
  305. Interactive
  306. </button>
  307. <button
  308. @click="switchToJsonTab"
  309. :class="['tab-button', { active: defaultConfigTab === 'json' }]"
  310. >
  311. JSON
  312. </button>
  313. </div>
  314. <div class="modal-body">
  315. <p style="margin-bottom: 16px; color: #718096; font-size: 14px;">
  316. This configuration will be copied to all newly registered devices.
  317. Changes do not affect existing devices.
  318. </p>
  319. <!-- Interactive Tab -->
  320. <div v-if="defaultConfigTab === 'interactive'">
  321. <!-- WiFi Scanner Section -->
  322. <div class="config-section">
  323. <h3>WiFi Scanner</h3>
  324. <div class="toggle-row">
  325. <span>WiFi Scanner Enabled</span>
  326. <label class="toggle-switch">
  327. <input type="checkbox" v-model="defaultConfig.wifi.monitor_enabled" @change="onDefaultWifiMonitorChange" />
  328. <span class="toggle-slider"></span>
  329. </label>
  330. </div>
  331. <div v-if="defaultConfig.wifi.monitor_enabled" class="toggle-content">
  332. <div class="form-group">
  333. <label>Batch Interval (ms)</label>
  334. <input v-model.number="defaultConfig.wifi.batch_interval_ms" type="number" min="1000" step="1000" />
  335. </div>
  336. <div class="form-group">
  337. <label>Upload Endpoint (optional)</label>
  338. <input v-model="defaultConfig.wifi.upload_endpoint" type="text" placeholder="http://custom.example.com:8080/wifi" />
  339. <div class="form-hint">Custom upload endpoint URL (leave empty for default)</div>
  340. </div>
  341. </div>
  342. </div>
  343. <!-- BLE Section -->
  344. <div class="config-section">
  345. <h3>BLE Scanner</h3>
  346. <div class="toggle-row">
  347. <span>BLE Scanner Enabled</span>
  348. <label class="toggle-switch">
  349. <input type="checkbox" v-model="defaultConfig.ble.enabled" />
  350. <span class="toggle-slider"></span>
  351. </label>
  352. </div>
  353. <div v-if="defaultConfig.ble.enabled" class="toggle-content">
  354. <div class="form-group">
  355. <label>Batch Interval (ms)</label>
  356. <input v-model.number="defaultConfig.ble.batch_interval_ms" type="number" min="100" step="100" />
  357. </div>
  358. <div class="form-group">
  359. <label>UUID Filter</label>
  360. <input v-model="defaultConfig.ble.uuid_filter_hex" type="text" placeholder="f7826da64fa24e988024bc5b71e0893e" maxlength="32" />
  361. <div class="form-hint">Filter beacons by UUID (32 hex chars, optional)</div>
  362. </div>
  363. <div class="form-group">
  364. <label>Upload Endpoint (optional)</label>
  365. <input v-model="defaultConfig.ble.upload_endpoint" type="text" placeholder="http://custom.example.com:8080/ble" />
  366. <div class="form-hint">Custom upload endpoint URL (leave empty for default)</div>
  367. </div>
  368. </div>
  369. </div>
  370. <!-- WiFi Client Section -->
  371. <div class="config-section">
  372. <h3>WiFi Client</h3>
  373. <div class="toggle-row">
  374. <span>WiFi Client Enabled</span>
  375. <label class="toggle-switch">
  376. <input type="checkbox" v-model="defaultConfig.wifi.client_enabled" @change="onDefaultWifiClientChange" />
  377. <span class="toggle-slider"></span>
  378. </label>
  379. </div>
  380. <div v-if="defaultConfig.wifi.client_enabled" class="toggle-content">
  381. <div class="form-row">
  382. <div class="form-group">
  383. <label>WiFi SSID</label>
  384. <input v-model="defaultConfig.wifi.ssid" type="text" />
  385. </div>
  386. <div class="form-group">
  387. <label>WiFi Password</label>
  388. <input v-model="defaultConfig.wifi.psk" type="password" />
  389. </div>
  390. </div>
  391. </div>
  392. </div>
  393. <!-- NTP Section -->
  394. <div class="config-section">
  395. <h3>NTP Servers</h3>
  396. <div class="form-group">
  397. <label>NTP Servers</label>
  398. <input v-model="defaultNtpServersText" type="text" placeholder="pool.ntp.org, time.google.com" />
  399. <div class="form-hint">Comma-separated list of NTP server addresses</div>
  400. </div>
  401. </div>
  402. <!-- Other Settings -->
  403. <div class="config-section">
  404. <h3>Other</h3>
  405. <div class="form-group">
  406. <label>Config Polling Timeout (seconds)</label>
  407. <input v-model.number="defaultConfig.cfg_polling_timeout" type="number" min="5" max="300" step="5" />
  408. <div class="form-hint">How often device fetches config from server (min: 5s, recommended: 30-300s)</div>
  409. </div>
  410. <div class="toggle-row">
  411. <span>Force Cloud Mode</span>
  412. <label class="toggle-switch">
  413. <input type="checkbox" v-model="defaultConfig.force_cloud" />
  414. <span class="toggle-slider"></span>
  415. </label>
  416. </div>
  417. <div class="toggle-row">
  418. <span>Debug Logging</span>
  419. <label class="toggle-switch">
  420. <input type="checkbox" v-model="defaultConfig.debug" />
  421. <span class="toggle-slider"></span>
  422. </label>
  423. </div>
  424. <div class="toggle-row">
  425. <span>Dashboard Enabled</span>
  426. <label class="toggle-switch">
  427. <input type="checkbox" v-model="defaultConfig.dashboard.enabled" />
  428. <span class="toggle-slider"></span>
  429. </label>
  430. </div>
  431. </div>
  432. </div>
  433. <!-- JSON Tab -->
  434. <div v-if="defaultConfigTab === 'json'">
  435. <div class="form-group">
  436. <label>Configuration (JSON)</label>
  437. <textarea
  438. v-model="defaultConfigJson"
  439. rows="25"
  440. class="config-editor"
  441. @input="validateJson"
  442. ></textarea>
  443. <div v-if="jsonError" class="form-error">{{ jsonError }}</div>
  444. </div>
  445. </div>
  446. </div>
  447. <div class="modal-footer">
  448. <button type="button" @click="closeDefaultConfigModal" class="btn-secondary">Cancel</button>
  449. <button
  450. type="button"
  451. @click="saveDefaultConfig"
  452. :disabled="savingDefaultConfig || (defaultConfigTab === 'json' && !!jsonError)"
  453. class="btn-primary"
  454. >
  455. {{ savingDefaultConfig ? 'Saving...' : 'Save Default Config' }}
  456. </button>
  457. </div>
  458. </div>
  459. </div>
  460. </div>
  461. </template>
  462. <script setup>
  463. import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
  464. import devicesApi from '@/api/devices'
  465. import organizationsApi from '@/api/organizations'
  466. import tunnelsApi from '@/api/tunnels'
  467. import settingsApi from '@/api/settings'
  468. // Auto-registration state
  469. const autoRegistrationEnabled = ref(false)
  470. const togglingRegistration = ref(false)
  471. const registrationTimeLeft = ref(0)
  472. let registrationTimer = null
  473. const devices = ref([])
  474. const organizations = ref([])
  475. const loading = ref(false)
  476. const error = ref(null)
  477. const modalVisible = ref(false)
  478. const editingDevice = ref(null)
  479. const saving = ref(false)
  480. const searchQuery = ref('')
  481. const onlineOnly = ref(false)
  482. const sortColumn = ref('simple_id')
  483. const sortDirection = ref('desc')
  484. const ntpServersText = ref('')
  485. const configTab = ref('interactive')
  486. const configJson = ref('')
  487. const configJsonError = ref(null)
  488. const originalDeviceConfig = ref(null)
  489. const tunnelLoading = ref({})
  490. const defaultConfigModalVisible = ref(false)
  491. const defaultConfigTab = ref('interactive')
  492. const defaultConfigJson = ref('')
  493. const savingDefaultConfig = ref(false)
  494. const jsonError = ref(null)
  495. const defaultNtpServersText = ref('')
  496. // Default config is loaded from backend API, no hardcoded values
  497. const defaultConfig = ref({})
  498. let searchDebounceTimer = null
  499. let pollingInterval = null
  500. // Device config is loaded from device data, no hardcoded values
  501. const config = ref({})
  502. const sortedDevices = computed(() => {
  503. let result = [...devices.value]
  504. // Sort
  505. result.sort((a, b) => {
  506. let aVal = a[sortColumn.value]
  507. let bVal = b[sortColumn.value]
  508. // Handle nulls
  509. if (aVal === null || aVal === undefined) return 1
  510. if (bVal === null || bVal === undefined) return -1
  511. // String comparison
  512. if (typeof aVal === 'string') {
  513. aVal = aVal.toLowerCase()
  514. bVal = bVal.toLowerCase()
  515. }
  516. if (aVal < bVal) return sortDirection.value === 'asc' ? -1 : 1
  517. if (aVal > bVal) return sortDirection.value === 'asc' ? 1 : -1
  518. return 0
  519. })
  520. return result
  521. })
  522. function sortBy(column) {
  523. if (sortColumn.value === column) {
  524. sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
  525. } else {
  526. sortColumn.value = column
  527. sortDirection.value = 'asc'
  528. }
  529. }
  530. async function loadDevices(silent = false) {
  531. // Only show loading spinner on initial load, not on polling updates
  532. if (!silent) {
  533. loading.value = true
  534. }
  535. error.value = null
  536. try {
  537. const params = {}
  538. if (searchQuery.value && searchQuery.value.length >= 2) {
  539. params.search = searchQuery.value
  540. }
  541. if (onlineOnly.value) {
  542. params.status = 'online'
  543. }
  544. devices.value = await devicesApi.getAllSuperadmin(params)
  545. } catch (err) {
  546. error.value = err.response?.data?.detail || 'Failed to load devices'
  547. } finally {
  548. loading.value = false
  549. }
  550. }
  551. function onSearch() {
  552. // Debounce search - wait 300ms after user stops typing
  553. clearTimeout(searchDebounceTimer)
  554. searchDebounceTimer = setTimeout(() => {
  555. loadDevices()
  556. }, 300)
  557. }
  558. function clearSearch() {
  559. searchQuery.value = ''
  560. loadDevices()
  561. }
  562. async function loadOrganizations() {
  563. try {
  564. organizations.value = await organizationsApi.getAll()
  565. } catch (err) {
  566. console.error('Failed to load organizations:', err)
  567. }
  568. }
  569. function formatDate(dateStr) {
  570. if (!dateStr) return 'Never'
  571. const date = new Date(dateStr)
  572. return date.toLocaleString()
  573. }
  574. function formatRelativeTime(dateStr) {
  575. if (!dateStr) return 'Never'
  576. const now = new Date()
  577. const then = new Date(dateStr)
  578. const diffMs = now - then
  579. const diffMinutes = Math.floor(diffMs / 60000)
  580. if (diffMinutes < 1) {
  581. return 'Just now'
  582. } else if (diffMinutes < 120) {
  583. return `${diffMinutes} min ago`
  584. } else {
  585. const diffHours = Math.floor(diffMinutes / 60)
  586. if (diffHours < 24) {
  587. return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`
  588. } else {
  589. const diffDays = Math.floor(diffHours / 24)
  590. return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`
  591. }
  592. }
  593. }
  594. function showEditModal(device) {
  595. editingDevice.value = device
  596. // Store original config for merging (don't lose ssh_tunnel, etc)
  597. originalDeviceConfig.value = JSON.parse(JSON.stringify(device.config || {}))
  598. // Extract only fields we edit in interactive mode
  599. config.value = {
  600. force_cloud: device.config?.force_cloud ?? false,
  601. cfg_polling_timeout: device.config?.cfg_polling_timeout ?? 30,
  602. ble: {
  603. enabled: device.config?.ble?.enabled ?? true,
  604. batch_interval_ms: device.config?.ble?.batch_interval_ms ?? 2500,
  605. uuid_filter_hex: device.config?.ble?.uuid_filter_hex ?? '',
  606. upload_endpoint: device.config?.ble?.upload_endpoint ?? ''
  607. },
  608. wifi: {
  609. client_enabled: device.config?.wifi?.client_enabled ?? false,
  610. ssid: device.config?.wifi?.ssid ?? '',
  611. psk: device.config?.wifi?.psk ?? '',
  612. monitor_enabled: device.config?.wifi?.monitor_enabled ?? true,
  613. batch_interval_ms: device.config?.wifi?.batch_interval_ms ?? 10000,
  614. upload_endpoint: device.config?.wifi?.upload_endpoint ?? ''
  615. },
  616. dashboard: {
  617. enabled: device.config?.dashboard?.enabled ?? true
  618. },
  619. net: {
  620. ntp: {
  621. servers: device.config?.net?.ntp?.servers ?? ['pool.ntp.org', 'time.google.com']
  622. }
  623. },
  624. debug: device.config?.debug ?? false
  625. }
  626. // Convert NTP servers array to comma-separated string
  627. ntpServersText.value = config.value.net.ntp.servers.join(', ')
  628. // JSON editor shows FULL config
  629. configJson.value = JSON.stringify(device.config || {}, null, 2)
  630. configJsonError.value = null
  631. configTab.value = 'interactive'
  632. modalVisible.value = true
  633. }
  634. function closeModal() {
  635. modalVisible.value = false
  636. editingDevice.value = null
  637. originalDeviceConfig.value = null
  638. configTab.value = 'interactive'
  639. configJson.value = ''
  640. configJsonError.value = null
  641. }
  642. function validateConfigJson() {
  643. try {
  644. JSON.parse(configJson.value)
  645. configJsonError.value = null
  646. } catch (e) {
  647. configJsonError.value = 'Invalid JSON: ' + e.message
  648. }
  649. }
  650. function onWifiClientChange() {
  651. // WiFi client и monitor взаимоисключающие (AIC8800 ограничение)
  652. if (config.value.wifi.client_enabled) {
  653. config.value.wifi.monitor_enabled = false
  654. }
  655. }
  656. function onWifiMonitorChange() {
  657. // WiFi client и monitor взаимоисключающие (AIC8800 ограничение)
  658. if (config.value.wifi.monitor_enabled) {
  659. config.value.wifi.client_enabled = false
  660. }
  661. }
  662. async function saveDevice() {
  663. saving.value = true
  664. try {
  665. let configToSave
  666. if (configTab.value === 'json') {
  667. // JSON mode: use JSON as-is
  668. configToSave = JSON.parse(configJson.value)
  669. } else {
  670. // Interactive mode: merge with original config to preserve ssh_tunnel, etc
  671. config.value.net.ntp.servers = ntpServersText.value
  672. .split(',')
  673. .map(s => s.trim())
  674. .filter(s => s.length > 0)
  675. // Deep merge: original config + interactive changes
  676. configToSave = {
  677. ...originalDeviceConfig.value,
  678. ...config.value,
  679. ble: { ...originalDeviceConfig.value?.ble, ...config.value.ble },
  680. wifi: { ...originalDeviceConfig.value?.wifi, ...config.value.wifi },
  681. dashboard: { ...originalDeviceConfig.value?.dashboard, ...config.value.dashboard },
  682. net: {
  683. ...originalDeviceConfig.value?.net,
  684. ntp: { ...originalDeviceConfig.value?.net?.ntp, ...config.value.net.ntp }
  685. }
  686. }
  687. }
  688. await devicesApi.updateSuperadmin(editingDevice.value.id, { config: configToSave })
  689. await loadDevices()
  690. closeModal()
  691. } catch (err) {
  692. alert(err.response?.data?.detail || 'Failed to save device')
  693. } finally {
  694. saving.value = false
  695. }
  696. }
  697. async function deleteDevice() {
  698. const deviceName = `#${editingDevice.value.simple_id} (${editingDevice.value.mac_address})`
  699. const confirmed = confirm(
  700. `Are you sure you want to DELETE device ${deviceName}?\n\n` +
  701. `This will permanently remove the device from the system.\n` +
  702. `The device will need to re-register to be used again.\n\n` +
  703. `This action CANNOT be undone!`
  704. )
  705. if (!confirmed) {
  706. return
  707. }
  708. saving.value = true
  709. try {
  710. await devicesApi.deleteSuperadmin(editingDevice.value.id)
  711. await loadDevices()
  712. closeModal()
  713. } catch (err) {
  714. alert(err.response?.data?.detail || 'Failed to delete device')
  715. saving.value = false
  716. }
  717. }
  718. async function openTunnel(device, tunnelType) {
  719. const loadingKey = `${device.id}:${tunnelType}`
  720. tunnelLoading.value[loadingKey] = true
  721. try {
  722. // Step 1: Enable tunnel, get session UUID
  723. const { session_uuid } = await tunnelsApi.enableTunnel(device.id, tunnelType)
  724. // Step 2: Poll for tunnel status
  725. const maxAttempts = 60 // 60 seconds max wait
  726. let attempts = 0
  727. let opened = false // Prevent multiple window.open()
  728. const pollInterval = setInterval(async () => {
  729. attempts++
  730. try {
  731. const status = await tunnelsApi.getSessionStatus(session_uuid)
  732. if (status.status === 'ready' && status.tunnel_url && !opened) {
  733. // Clear polling
  734. clearInterval(pollInterval)
  735. tunnelLoading.value[loadingKey] = false
  736. opened = true
  737. // Open tunnel URL in new tab
  738. window.open(status.tunnel_url, '_blank')
  739. } else if (status.status === 'failed') {
  740. clearInterval(pollInterval)
  741. tunnelLoading.value[loadingKey] = false
  742. alert('Failed to establish tunnel')
  743. } else if (attempts >= maxAttempts) {
  744. clearInterval(pollInterval)
  745. tunnelLoading.value[loadingKey] = false
  746. alert('Tunnel connection timeout')
  747. }
  748. } catch (err) {
  749. clearInterval(pollInterval)
  750. tunnelLoading.value[loadingKey] = false
  751. console.error('Failed to poll tunnel status:', err)
  752. alert('Failed to check tunnel status')
  753. }
  754. }, 1000) // Poll every 1 second
  755. } catch (err) {
  756. tunnelLoading.value[loadingKey] = false
  757. console.error('Failed to enable tunnel:', err)
  758. alert(err.response?.data?.detail || 'Failed to enable tunnel')
  759. }
  760. }
  761. function openSSH(device) {
  762. openTunnel(device, 'ssh')
  763. }
  764. function openDashboard(device) {
  765. openTunnel(device, 'dashboard')
  766. }
  767. async function toggleBLE(device, event) {
  768. event.preventDefault()
  769. const newState = !(device.config?.ble?.enabled ?? false)
  770. const action = newState ? 'enable' : 'disable'
  771. if (!confirm(`Are you sure you want to ${action} BLE scanner for device #${device.simple_id}?`)) {
  772. return
  773. }
  774. try {
  775. const updatedConfig = {
  776. ...device.config,
  777. ble: {
  778. ...(device.config?.ble || {}),
  779. enabled: newState
  780. }
  781. }
  782. await devicesApi.updateSuperadmin(device.id, { config: updatedConfig })
  783. await loadDevices()
  784. } catch (err) {
  785. alert(err.response?.data?.detail || 'Failed to update BLE scanner')
  786. }
  787. }
  788. async function toggleWiFi(device, event) {
  789. event.preventDefault()
  790. const newState = !(device.config?.wifi?.monitor_enabled ?? false)
  791. const action = newState ? 'enable' : 'disable'
  792. if (!confirm(`Are you sure you want to ${action} WiFi scanner for device #${device.simple_id}?`)) {
  793. return
  794. }
  795. try {
  796. const updatedConfig = {
  797. ...device.config,
  798. wifi: {
  799. ...(device.config?.wifi || {}),
  800. monitor_enabled: newState
  801. }
  802. }
  803. await devicesApi.updateSuperadmin(device.id, { config: updatedConfig })
  804. await loadDevices()
  805. } catch (err) {
  806. alert(err.response?.data?.detail || 'Failed to update WiFi scanner')
  807. }
  808. }
  809. async function showDefaultConfigModal() {
  810. try {
  811. const configData = await devicesApi.getDefaultConfig()
  812. // Load into interactive form
  813. defaultConfig.value = {
  814. force_cloud: configData.force_cloud ?? false,
  815. cfg_polling_timeout: configData.cfg_polling_timeout ?? 30,
  816. ble: {
  817. enabled: configData.ble?.enabled ?? true,
  818. batch_interval_ms: configData.ble?.batch_interval_ms ?? 2500,
  819. uuid_filter_hex: configData.ble?.uuid_filter_hex ?? '',
  820. upload_endpoint: configData.ble?.upload_endpoint ?? ''
  821. },
  822. wifi: {
  823. client_enabled: configData.wifi?.client_enabled ?? false,
  824. ssid: configData.wifi?.ssid ?? '',
  825. psk: configData.wifi?.psk ?? '',
  826. monitor_enabled: configData.wifi?.monitor_enabled ?? true,
  827. batch_interval_ms: configData.wifi?.batch_interval_ms ?? 10000,
  828. upload_endpoint: configData.wifi?.upload_endpoint ?? ''
  829. },
  830. ssh_tunnel: configData.ssh_tunnel || {},
  831. dashboard_tunnel: configData.dashboard_tunnel || {},
  832. dashboard: {
  833. enabled: configData.dashboard?.enabled ?? true
  834. },
  835. net: {
  836. ntp: {
  837. servers: configData.net?.ntp?.servers ?? ['pool.ntp.org', 'time.google.com']
  838. }
  839. },
  840. debug: configData.debug ?? false
  841. }
  842. // Convert NTP servers to text
  843. defaultNtpServersText.value = defaultConfig.value.net.ntp.servers.join(', ')
  844. // Also load into JSON
  845. defaultConfigJson.value = JSON.stringify(configData, null, 2)
  846. jsonError.value = null
  847. defaultConfigTab.value = 'interactive'
  848. defaultConfigModalVisible.value = true
  849. } catch (err) {
  850. alert(err.response?.data?.detail || 'Failed to load default config')
  851. }
  852. }
  853. function closeDefaultConfigModal() {
  854. defaultConfigModalVisible.value = false
  855. defaultConfigTab.value = 'interactive'
  856. defaultConfigJson.value = ''
  857. jsonError.value = null
  858. }
  859. function onDefaultWifiClientChange() {
  860. if (defaultConfig.value.wifi.client_enabled) {
  861. defaultConfig.value.wifi.monitor_enabled = false
  862. }
  863. }
  864. function onDefaultWifiMonitorChange() {
  865. if (defaultConfig.value.wifi.monitor_enabled) {
  866. defaultConfig.value.wifi.client_enabled = false
  867. }
  868. }
  869. function switchToJsonTab() {
  870. // Convert interactive form to JSON before switching
  871. defaultConfig.value.net.ntp.servers = defaultNtpServersText.value
  872. .split(',')
  873. .map(s => s.trim())
  874. .filter(s => s.length > 0)
  875. defaultConfigJson.value = JSON.stringify(defaultConfig.value, null, 2)
  876. defaultConfigTab.value = 'json'
  877. }
  878. function validateJson() {
  879. try {
  880. JSON.parse(defaultConfigJson.value)
  881. jsonError.value = null
  882. } catch (e) {
  883. jsonError.value = `Invalid JSON: ${e.message}`
  884. }
  885. }
  886. async function saveDefaultConfig() {
  887. try {
  888. let configToSave
  889. if (defaultConfigTab.value === 'interactive') {
  890. // Parse NTP servers from text
  891. defaultConfig.value.net.ntp.servers = defaultNtpServersText.value
  892. .split(',')
  893. .map(s => s.trim())
  894. .filter(s => s.length > 0)
  895. configToSave = defaultConfig.value
  896. } else {
  897. // Validate and parse JSON
  898. configToSave = JSON.parse(defaultConfigJson.value)
  899. }
  900. savingDefaultConfig.value = true
  901. await devicesApi.updateDefaultConfig(configToSave)
  902. alert('Default configuration saved successfully!')
  903. closeDefaultConfigModal()
  904. } catch (err) {
  905. if (err instanceof SyntaxError) {
  906. jsonError.value = `Invalid JSON: ${err.message}`
  907. } else {
  908. alert(err.response?.data?.detail || 'Failed to save default config')
  909. }
  910. } finally {
  911. savingDefaultConfig.value = false
  912. }
  913. }
  914. // Auto-registration functions
  915. async function loadAutoRegistrationStatus() {
  916. try {
  917. const status = await settingsApi.getAutoRegistrationStatus()
  918. autoRegistrationEnabled.value = status.enabled
  919. registrationTimeLeft.value = status.time_left || 0
  920. } catch (err) {
  921. console.error('Failed to load registration status:', err)
  922. }
  923. }
  924. async function toggleAutoRegistration() {
  925. togglingRegistration.value = true
  926. try {
  927. const newState = !autoRegistrationEnabled.value
  928. await settingsApi.toggleAutoRegistration(newState)
  929. autoRegistrationEnabled.value = newState
  930. if (newState) {
  931. startRegistrationTimer()
  932. } else {
  933. stopRegistrationTimer()
  934. }
  935. } catch (err) {
  936. console.error('Failed to toggle registration:', err)
  937. // Reload status to sync
  938. await loadAutoRegistrationStatus()
  939. } finally {
  940. togglingRegistration.value = false
  941. }
  942. }
  943. function startRegistrationTimer() {
  944. stopRegistrationTimer()
  945. registrationTimer = setInterval(async () => {
  946. await loadAutoRegistrationStatus()
  947. if (!autoRegistrationEnabled.value) {
  948. stopRegistrationTimer()
  949. }
  950. }, 10000) // Update every 10 seconds
  951. }
  952. function stopRegistrationTimer() {
  953. if (registrationTimer) {
  954. clearInterval(registrationTimer)
  955. registrationTimer = null
  956. }
  957. }
  958. function formatTimeLeft(seconds) {
  959. if (seconds < 60) return `${seconds}s`
  960. if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
  961. return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
  962. }
  963. onMounted(() => {
  964. loadDevices()
  965. loadOrganizations()
  966. loadAutoRegistrationStatus()
  967. startRegistrationTimer()
  968. // Real-time polling every 10 seconds (silent to avoid table flickering)
  969. pollingInterval = setInterval(() => {
  970. if (!modalVisible.value) {
  971. loadDevices(true)
  972. }
  973. }, 10000)
  974. })
  975. onBeforeUnmount(() => {
  976. if (pollingInterval) {
  977. clearInterval(pollingInterval)
  978. }
  979. if (searchDebounceTimer) {
  980. clearTimeout(searchDebounceTimer)
  981. }
  982. stopRegistrationTimer()
  983. })
  984. </script>
  985. <style scoped>
  986. .page { padding: 32px; }
  987. .page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px; }
  988. .page-header h1 { font-size: 32px; font-weight: 700; color: #1a202c; margin-bottom: 8px; }
  989. .page-header p { color: #718096; font-size: 16px; }
  990. /* Registration Banner */
  991. .registration-banner {
  992. background: #fff5f5;
  993. border: 2px solid #fc8181;
  994. border-radius: 12px;
  995. padding: 20px 24px;
  996. margin-bottom: 24px;
  997. transition: all 0.3s;
  998. }
  999. .registration-banner.enabled {
  1000. background: #f0fff4;
  1001. border-color: #68d391;
  1002. }
  1003. .registration-content {
  1004. display: flex;
  1005. justify-content: space-between;
  1006. align-items: center;
  1007. gap: 24px;
  1008. }
  1009. .registration-info h2 {
  1010. font-size: 20px;
  1011. font-weight: 700;
  1012. margin-bottom: 8px;
  1013. color: #1a202c;
  1014. }
  1015. .registration-info p {
  1016. font-size: 14px;
  1017. color: #718096;
  1018. margin: 0;
  1019. }
  1020. .registration-info .warning {
  1021. color: #c53030;
  1022. font-weight: 500;
  1023. }
  1024. .registration-banner.enabled .warning {
  1025. color: #276749;
  1026. }
  1027. /* Large Toggle Switch (for registration banner) */
  1028. .toggle-switch-large {
  1029. position: relative;
  1030. display: inline-block;
  1031. width: 72px;
  1032. height: 40px;
  1033. flex-shrink: 0;
  1034. }
  1035. .toggle-switch-large input {
  1036. opacity: 0;
  1037. width: 0;
  1038. height: 0;
  1039. position: absolute;
  1040. }
  1041. .slider-large {
  1042. position: absolute;
  1043. cursor: pointer;
  1044. top: 0;
  1045. left: 0;
  1046. right: 0;
  1047. bottom: 0;
  1048. background-color: #cbd5e0;
  1049. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  1050. border-radius: 40px;
  1051. box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
  1052. }
  1053. .slider-large:before {
  1054. position: absolute;
  1055. content: '';
  1056. height: 32px;
  1057. width: 32px;
  1058. left: 4px;
  1059. top: 4px;
  1060. background-color: white;
  1061. transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  1062. border-radius: 50%;
  1063. box-shadow: 0 2px 8px rgba(0,0,0,0.2);
  1064. }
  1065. .toggle-switch-large input:checked + .slider-large {
  1066. background-color: #48bb78;
  1067. }
  1068. .toggle-switch-large input:checked + .slider-large:before {
  1069. transform: translateX(32px);
  1070. }
  1071. .toggle-switch-large input:focus + .slider-large {
  1072. box-shadow: inset 0 2px 4px rgba(0,0,0,0.1), 0 0 0 3px rgba(72, 187, 120, 0.3);
  1073. }
  1074. .toggle-switch-large input:disabled + .slider-large {
  1075. opacity: 0.5;
  1076. cursor: not-allowed;
  1077. }
  1078. .toggle-switch-large .spinner {
  1079. position: absolute;
  1080. left: 50%;
  1081. top: 50%;
  1082. transform: translate(-50%, -50%);
  1083. font-size: 18px;
  1084. animation: spin 1s linear infinite;
  1085. z-index: 10;
  1086. }
  1087. .content { background: white; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
  1088. /* Filters Row */
  1089. .filters-row { display: flex; gap: 16px; align-items: center; margin-bottom: 20px; }
  1090. .btn-edit-default { margin-left: auto; }
  1091. .search-box { position: relative; flex: 1; max-width: 400px; }
  1092. .search-input { width: 100%; padding: 10px 40px 10px 14px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; }
  1093. .search-input:focus { outline: none; border-color: #667eea; }
  1094. .search-input::placeholder { color: #a0aec0; }
  1095. .search-clear { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); font-size: 24px; color: #a0aec0; cursor: pointer; line-height: 1; padding: 0 4px; }
  1096. .search-clear:hover { color: #718096; }
  1097. .filter-checkbox { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; white-space: nowrap; }
  1098. .filter-checkbox input[type="checkbox"] { cursor: pointer; width: 16px; height: 16px; }
  1099. .filter-checkbox span { color: #4a5568; font-size: 14px; }
  1100. .loading, .error, .empty { text-align: center; padding: 40px; color: #718096; }
  1101. .error { color: #e53e3e; }
  1102. .data-table { width: 100%; border-collapse: collapse; }
  1103. .data-table th { text-align: left; padding: 8px 12px; border-bottom: 2px solid #e2e8f0; font-weight: 600; color: #4a5568; font-size: 13px; cursor: pointer; user-select: none; }
  1104. .data-table th:hover { background: #f7fafc; }
  1105. .data-table td { padding: 6px 12px; border-bottom: 1px solid #e2e8f0; color: #1a202c; font-size: 14px; }
  1106. .data-table tbody tr:hover { background: #f7fafc; }
  1107. code { background: #f7fafc; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 13px; }
  1108. .badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; background: #e2e8f0; color: #718096; }
  1109. .badge.status-online { background: #c6f6d5; color: #22543d; }
  1110. .badge.status-offline { background: #e2e8f0; color: #718096; }
  1111. .badge.status-error { background: #fed7d7; color: #742a2a; }
  1112. .btn-icon { padding: 6px 10px; background: none; border: none; cursor: pointer; font-size: 16px; opacity: 0.7; transition: opacity 0.2s; }
  1113. .btn-icon:hover { opacity: 1; background: #f7fafc; border-radius: 4px; }
  1114. .btn-icon:disabled { opacity: 0.5; cursor: not-allowed; }
  1115. .btn-icon:disabled:hover { background: none; }
  1116. .btn-icon.loading .spinner { display: inline-block; animation: spin 1s linear infinite; }
  1117. @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
  1118. .btn-primary { padding: 12px 24px; background: #667eea; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
  1119. .btn-primary:hover { background: #5568d3; }
  1120. .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
  1121. .btn-secondary { padding: 12px 24px; background: #e2e8f0; color: #4a5568; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
  1122. .btn-secondary:hover { background: #cbd5e0; }
  1123. .btn-danger { padding: 12px 24px; background: #f56565; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: all 0.2s; }
  1124. .btn-danger:hover { background: #e53e3e; }
  1125. .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
  1126. .modal { background: white; border-radius: 12px; width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; }
  1127. .modal-sm { max-width: 400px; }
  1128. .modal-wide { max-width: 800px; }
  1129. .modal-header { display: flex; justify-content: space-between; align-items: center; padding: 24px; border-bottom: 1px solid #e2e8f0; }
  1130. .modal-header h2 { font-size: 24px; font-weight: 700; color: #1a202c; }
  1131. .btn-close { width: 32px; height: 32px; border: none; background: none; font-size: 32px; color: #718096; cursor: pointer; line-height: 1; }
  1132. .btn-close:hover { color: #1a202c; }
  1133. .modal-tabs { display: flex; border-bottom: 2px solid #e2e8f0; padding: 0 24px; }
  1134. .tab-button { padding: 12px 24px; background: none; border: none; border-bottom: 3px solid transparent; color: #718096; font-weight: 500; font-size: 14px; cursor: pointer; transition: all 0.2s; margin-bottom: -2px; }
  1135. .tab-button:hover { color: #4a5568; background: #f7fafc; }
  1136. .tab-button.active { color: #667eea; border-bottom-color: #667eea; }
  1137. .modal-body { padding: 16px; }
  1138. .modal-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 16px; border-top: 1px solid #e2e8f0; }
  1139. .btn-delete { margin-right: auto; }
  1140. .form-group { margin-bottom: 12px; }
  1141. .form-group label { display: block; margin-bottom: 4px; font-weight: 500; color: #4a5568; font-size: 13px; }
  1142. .form-group input, .form-group select { width: 100%; padding: 8px 10px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; transition: border-color 0.2s; }
  1143. .form-group input:focus, .form-group select:focus { outline: none; border-color: #667eea; }
  1144. .form-group input:disabled { background: #f7fafc; color: #718096; }
  1145. .form-hint { margin-top: 4px; font-size: 11px; color: #718096; }
  1146. .config-editor { width: 100%; padding: 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 13px; line-height: 1.5; resize: vertical; transition: border-color 0.2s; }
  1147. .config-editor:focus { outline: none; border-color: #667eea; }
  1148. .form-error { margin-top: 8px; padding: 8px 12px; background: #fed7d7; color: #742a2a; border-radius: 6px; font-size: 13px; }
  1149. /* Config sections */
  1150. .config-section { border: 1px solid #e2e8f0; border-radius: 6px; padding: 12px; margin-bottom: 12px; background: #fafafa; }
  1151. .config-section h3 { margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #2d3748; border-bottom: 1px solid #e2e8f0; padding-bottom: 6px; }
  1152. .form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
  1153. /* Toggle row */
  1154. .toggle-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding: 8px 0; }
  1155. .toggle-row:last-child { margin-bottom: 0; }
  1156. .toggle-row span { font-size: 13px; font-weight: 500; color: #4a5568; }
  1157. /* Toggle switch (for modals) */
  1158. .toggle-switch { position: relative; display: inline-block; width: 48px; height: 26px; }
  1159. .toggle-switch input { opacity: 0; width: 0; height: 0; }
  1160. .toggle-switch .toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #cbd5e0; border-radius: 26px; transition: 0.3s; }
  1161. .toggle-switch .toggle-slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; border-radius: 50%; transition: 0.3s; }
  1162. .toggle-switch input:checked + .toggle-slider { background-color: #667eea; }
  1163. .toggle-switch input:checked + .toggle-slider:before { transform: translateX(22px); }
  1164. .toggle-switch input:disabled + .toggle-slider { opacity: 0.5; cursor: not-allowed; }
  1165. /* Toggle switch inline (for table cells) */
  1166. .toggle-switch-inline { position: relative; display: inline-block; width: 38px; height: 20px; }
  1167. .toggle-switch-inline input { opacity: 0; width: 0; height: 0; }
  1168. .toggle-switch-inline .toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #cbd5e0; border-radius: 20px; transition: 0.3s; }
  1169. .toggle-switch-inline .toggle-slider:before { position: absolute; content: ""; height: 14px; width: 14px; left: 3px; bottom: 3px; background-color: white; border-radius: 50%; transition: 0.3s; }
  1170. .toggle-switch-inline input:checked + .toggle-slider { background-color: #667eea; }
  1171. .toggle-switch-inline input:checked + .toggle-slider:before { transform: translateX(18px); }
  1172. .toggle-switch-inline input:disabled + .toggle-slider { opacity: 0.5; cursor: not-allowed; }
  1173. .text-center { text-align: center; }
  1174. /* Toggle content (expanded section) */
  1175. .toggle-content { padding-left: 0; margin-top: 8px; border-top: 1px solid #e2e8f0; padding-top: 12px; }
  1176. .toggle-content .form-group:last-child { margin-bottom: 0; }
  1177. </style>