feat: add web dashboard
This commit is contained in:
parent
2e6dc71bf1
commit
9033e09ac1
9 changed files with 966 additions and 1 deletions
397
public/dashboard/app.js
Normal file
397
public/dashboard/app.js
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
(function () {
|
||||
const API_BASE = window.location.pathname.slice(0, window.location.pathname.lastIndexOf('/dashboard'))
|
||||
const STORAGE_KEY = 'wwebjsApiKey'
|
||||
// must match sessionNameValidation in src/middleware.js
|
||||
const SESSION_ID_PATTERN = /^[\w-]+$/
|
||||
const POLL_INTERVAL_MS = 5000
|
||||
const QR_POLL_INTERVAL_MS = 3000
|
||||
|
||||
const $ = (id) => document.getElementById(id)
|
||||
|
||||
let pollTimer = null
|
||||
let qrTimer = null
|
||||
let qrSessionId = null
|
||||
let qrObjectUrl = null
|
||||
let authFailed = false
|
||||
|
||||
async function apiFetch (path, options = {}) {
|
||||
const headers = Object.assign({}, options.headers)
|
||||
const apiKey = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (apiKey) {
|
||||
headers['x-api-key'] = apiKey
|
||||
}
|
||||
const response = await window.fetch(API_BASE + path, Object.assign({}, options, { headers, cache: 'no-store' }))
|
||||
if (response.status === 403) {
|
||||
authFailed = true
|
||||
$('apiKeyBanner').classList.remove('hidden')
|
||||
throw new Error('Invalid or missing API key')
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function apiJson (path, options) {
|
||||
const response = await apiFetch(path, options)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
function showToast (message, isError) {
|
||||
const toast = document.createElement('div')
|
||||
toast.className = 'toast' + (isError ? ' toast-error' : '')
|
||||
toast.textContent = message
|
||||
$('toasts').appendChild(toast)
|
||||
setTimeout(() => toast.remove(), 4000)
|
||||
}
|
||||
|
||||
/*
|
||||
* Sessions list
|
||||
*/
|
||||
|
||||
async function refreshSessions () {
|
||||
const body = await apiJson('/session/getSessions')
|
||||
if (!body.success) {
|
||||
throw new Error(body.error || 'Failed to fetch sessions')
|
||||
}
|
||||
const ids = body.result.sort()
|
||||
const statuses = await Promise.all(
|
||||
ids.map((id) => apiJson('/session/status/' + encodeURIComponent(id)).catch(() => null))
|
||||
)
|
||||
renderSessions(ids, statuses)
|
||||
}
|
||||
|
||||
function renderSessions (ids, statuses) {
|
||||
const tbody = $('sessionsBody')
|
||||
tbody.textContent = ''
|
||||
$('emptyState').classList.toggle('hidden', ids.length > 0)
|
||||
ids.forEach((id, index) => {
|
||||
const status = statuses[index]
|
||||
const connected = Boolean(status && status.success && status.state === 'CONNECTED')
|
||||
const row = document.createElement('tr')
|
||||
|
||||
const idCell = document.createElement('td')
|
||||
idCell.className = 'session-id'
|
||||
idCell.textContent = id
|
||||
row.appendChild(idCell)
|
||||
|
||||
const statusCell = document.createElement('td')
|
||||
const badge = document.createElement('span')
|
||||
badge.className = 'badge ' + (connected ? 'badge-connected' : 'badge-pending')
|
||||
badge.textContent = (status && status.state) || 'STARTING'
|
||||
statusCell.appendChild(badge)
|
||||
row.appendChild(statusCell)
|
||||
|
||||
const actionsCell = document.createElement('td')
|
||||
actionsCell.className = 'actions-cell'
|
||||
const actions = document.createElement('div')
|
||||
actions.className = 'actions'
|
||||
if (connected) {
|
||||
actions.appendChild(actionButton('Details', () => openDetailsModal(id)))
|
||||
} else {
|
||||
actions.appendChild(actionButton('Show QR', () => openQrModal(id)))
|
||||
}
|
||||
actions.appendChild(actionButton('Restart', () => sessionAction(id, 'restart')))
|
||||
actions.appendChild(actionButton('Stop', () => sessionAction(id, 'stop')))
|
||||
actions.appendChild(actionButton('Terminate', () => sessionAction(id, 'terminate', 'Terminate session "' + id + '"? This logs out the linked device.'), 'danger'))
|
||||
actionsCell.appendChild(actions)
|
||||
row.appendChild(actionsCell)
|
||||
|
||||
tbody.appendChild(row)
|
||||
})
|
||||
}
|
||||
|
||||
function actionButton (label, onClick, extraClass) {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.className = 'secondary' + (extraClass ? ' ' + extraClass : '')
|
||||
button.textContent = label
|
||||
button.addEventListener('click', onClick)
|
||||
return button
|
||||
}
|
||||
|
||||
async function sessionAction (id, action, confirmMessage) {
|
||||
if (confirmMessage && !window.confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await apiJson('/session/' + action + '/' + encodeURIComponent(id))
|
||||
if (body.success) {
|
||||
showToast(id + ': ' + (body.message || action + ' completed'))
|
||||
} else {
|
||||
showToast(id + ': ' + (body.error || body.message || action + ' failed'), true)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, true)
|
||||
}
|
||||
refreshNow()
|
||||
}
|
||||
|
||||
async function globalAction (path, confirmMessage) {
|
||||
if (confirmMessage && !window.confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const body = await apiJson(path)
|
||||
showToast(body.message || body.error || 'Done', !body.success)
|
||||
} catch (error) {
|
||||
showToast(error.message, true)
|
||||
}
|
||||
refreshNow()
|
||||
}
|
||||
|
||||
/*
|
||||
* Polling
|
||||
*/
|
||||
|
||||
function refreshNow () {
|
||||
clearTimeout(pollTimer)
|
||||
pollLoop()
|
||||
}
|
||||
|
||||
async function pollLoop () {
|
||||
if (!document.hidden && !authFailed) {
|
||||
try {
|
||||
await refreshSessions()
|
||||
} catch {
|
||||
// errors surface through the API key banner or action toasts
|
||||
}
|
||||
}
|
||||
pollTimer = setTimeout(pollLoop, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/*
|
||||
* Add session
|
||||
*/
|
||||
|
||||
function openStartModal () {
|
||||
$('newSessionId').value = ''
|
||||
$('addSessionError').classList.add('hidden')
|
||||
$('startModal').classList.remove('hidden')
|
||||
$('newSessionId').focus()
|
||||
}
|
||||
|
||||
function closeStartModal () {
|
||||
$('startModal').classList.add('hidden')
|
||||
}
|
||||
|
||||
async function startNewSession (event) {
|
||||
event.preventDefault()
|
||||
const input = $('newSessionId')
|
||||
const errorEl = $('addSessionError')
|
||||
const id = input.value.trim()
|
||||
if (!SESSION_ID_PATTERN.test(id)) {
|
||||
errorEl.textContent = 'Session id should be alphanumerical or -'
|
||||
errorEl.classList.remove('hidden')
|
||||
return
|
||||
}
|
||||
errorEl.classList.add('hidden')
|
||||
const button = $('startSubmitBtn')
|
||||
button.disabled = true
|
||||
button.textContent = 'Starting…'
|
||||
try {
|
||||
const body = await apiJson('/session/start/' + encodeURIComponent(id))
|
||||
if (body.success) {
|
||||
showToast(id + ': ' + body.message)
|
||||
closeStartModal()
|
||||
openQrModal(id)
|
||||
} else {
|
||||
showToast(id + ': ' + (body.error || body.message), true)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, true)
|
||||
}
|
||||
button.disabled = false
|
||||
button.textContent = 'Start'
|
||||
refreshNow()
|
||||
}
|
||||
|
||||
/*
|
||||
* QR modal
|
||||
*/
|
||||
|
||||
function openQrModal (id) {
|
||||
clearTimeout(qrTimer)
|
||||
qrSessionId = id
|
||||
$('qrSessionName').textContent = id
|
||||
$('qrImage').classList.add('hidden')
|
||||
$('qrConnected').classList.add('hidden')
|
||||
$('qrWaiting').textContent = 'Waiting for QR code…'
|
||||
$('qrWaiting').classList.remove('hidden')
|
||||
$('pairingCode').classList.add('hidden')
|
||||
$('pairingPhone').value = ''
|
||||
$('qrModal').classList.remove('hidden')
|
||||
qrTick()
|
||||
}
|
||||
|
||||
async function qrTick () {
|
||||
try {
|
||||
const status = await apiJson('/session/status/' + encodeURIComponent(qrSessionId))
|
||||
if (status.message === 'session_not_found') {
|
||||
$('qrImage').classList.add('hidden')
|
||||
$('qrWaiting').classList.remove('hidden')
|
||||
$('qrWaiting').textContent = 'Session no longer exists'
|
||||
refreshNow()
|
||||
return
|
||||
}
|
||||
if (status.success && status.state === 'CONNECTED') {
|
||||
$('qrWaiting').classList.add('hidden')
|
||||
$('qrImage').classList.add('hidden')
|
||||
$('qrConnected').classList.remove('hidden')
|
||||
refreshNow()
|
||||
return
|
||||
}
|
||||
const response = await apiFetch('/session/qr/' + encodeURIComponent(qrSessionId) + '/image')
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
// the endpoint responds 200 with a JSON body when the QR is not ready
|
||||
if (contentType.startsWith('image/png')) {
|
||||
const blob = await response.blob()
|
||||
if (qrObjectUrl) {
|
||||
URL.revokeObjectURL(qrObjectUrl)
|
||||
}
|
||||
qrObjectUrl = URL.createObjectURL(blob)
|
||||
const image = $('qrImage')
|
||||
image.src = qrObjectUrl
|
||||
image.classList.remove('hidden')
|
||||
$('qrWaiting').classList.add('hidden')
|
||||
}
|
||||
} catch {
|
||||
// keep polling; auth errors surface through the API key banner
|
||||
}
|
||||
qrTimer = setTimeout(qrTick, QR_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function closeQrModal () {
|
||||
clearTimeout(qrTimer)
|
||||
if (qrObjectUrl) {
|
||||
URL.revokeObjectURL(qrObjectUrl)
|
||||
qrObjectUrl = null
|
||||
}
|
||||
$('qrModal').classList.add('hidden')
|
||||
refreshNow()
|
||||
}
|
||||
|
||||
async function requestPairingCode () {
|
||||
const phoneNumber = $('pairingPhone').value.replace(/\D/g, '')
|
||||
if (!phoneNumber) {
|
||||
showToast('Enter the phone number in international format, digits only', true)
|
||||
return
|
||||
}
|
||||
const button = $('pairingBtn')
|
||||
button.disabled = true
|
||||
try {
|
||||
const body = await apiJson('/session/requestPairingCode/' + encodeURIComponent(qrSessionId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phoneNumber, showNotification: true })
|
||||
})
|
||||
if (body.success) {
|
||||
const codeEl = $('pairingCode')
|
||||
codeEl.textContent = body.result
|
||||
codeEl.classList.remove('hidden')
|
||||
} else {
|
||||
showToast(body.error || body.message || 'Failed to request pairing code', true)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, true)
|
||||
}
|
||||
button.disabled = false
|
||||
}
|
||||
|
||||
/*
|
||||
* Details modal
|
||||
*/
|
||||
|
||||
async function openDetailsModal (id) {
|
||||
$('detailsSessionName').textContent = id
|
||||
const list = $('detailsList')
|
||||
list.textContent = ''
|
||||
$('detailsModal').classList.remove('hidden')
|
||||
try {
|
||||
const status = await apiJson('/session/status/' + encodeURIComponent(id))
|
||||
addDetail(list, 'State', status.state || status.message)
|
||||
if (status.success && status.state === 'CONNECTED') {
|
||||
const body = await apiJson('/client/getClassInfo/' + encodeURIComponent(id))
|
||||
const info = body.sessionInfo || {}
|
||||
addDetail(list, 'Phone number', info.wid && info.wid.user)
|
||||
addDetail(list, 'Push name', info.pushname)
|
||||
addDetail(list, 'Platform', info.platform)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, true)
|
||||
}
|
||||
}
|
||||
|
||||
function addDetail (list, label, value) {
|
||||
const dt = document.createElement('dt')
|
||||
dt.textContent = label
|
||||
const dd = document.createElement('dd')
|
||||
dd.textContent = value || '—'
|
||||
list.appendChild(dt)
|
||||
list.appendChild(dd)
|
||||
}
|
||||
|
||||
/*
|
||||
* API key
|
||||
*/
|
||||
|
||||
function saveApiKey () {
|
||||
const value = $('apiKeyInput').value.trim()
|
||||
if (value) {
|
||||
window.localStorage.setItem(STORAGE_KEY, value)
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
authFailed = false
|
||||
$('apiKeyBanner').classList.add('hidden')
|
||||
showToast('API key saved')
|
||||
refreshNow()
|
||||
}
|
||||
|
||||
// the swagger endpoint is optional (ENABLE_SWAGGER_ENDPOINT); only show the link when it responds
|
||||
async function checkApiDocs () {
|
||||
try {
|
||||
const response = await window.fetch(API_BASE + '/api-docs', { method: 'HEAD', cache: 'no-store' })
|
||||
if (response.ok) {
|
||||
$('apiDocsLink').classList.remove('hidden')
|
||||
}
|
||||
} catch {
|
||||
// endpoint unavailable; keep the link hidden
|
||||
}
|
||||
}
|
||||
|
||||
function init () {
|
||||
$('apiKeyInput').value = window.localStorage.getItem(STORAGE_KEY) || ''
|
||||
checkApiDocs()
|
||||
$('apiKeySaveBtn').addEventListener('click', saveApiKey)
|
||||
$('addSessionBtn').addEventListener('click', openStartModal)
|
||||
$('addSessionForm').addEventListener('submit', startNewSession)
|
||||
$('startCloseBtn').addEventListener('click', closeStartModal)
|
||||
$('refreshBtn').addEventListener('click', () => refreshNow())
|
||||
$('terminateInactiveBtn').addEventListener('click', () => globalAction('/session/terminateInactive', 'Terminate all inactive sessions?'))
|
||||
$('terminateAllBtn').addEventListener('click', () => globalAction('/session/terminateAll', 'Terminate ALL sessions? This logs out every linked device.'))
|
||||
$('qrCloseBtn').addEventListener('click', closeQrModal)
|
||||
$('pairingBtn').addEventListener('click', requestPairingCode)
|
||||
$('detailsCloseBtn').addEventListener('click', () => $('detailsModal').classList.add('hidden'))
|
||||
$('startModal').addEventListener('click', (event) => {
|
||||
if (event.target === $('startModal')) {
|
||||
closeStartModal()
|
||||
}
|
||||
})
|
||||
$('qrModal').addEventListener('click', (event) => {
|
||||
if (event.target === $('qrModal')) {
|
||||
closeQrModal()
|
||||
}
|
||||
})
|
||||
$('detailsModal').addEventListener('click', (event) => {
|
||||
if (event.target === $('detailsModal')) {
|
||||
$('detailsModal').classList.add('hidden')
|
||||
}
|
||||
})
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
refreshNow()
|
||||
}
|
||||
})
|
||||
pollLoop()
|
||||
}
|
||||
|
||||
init()
|
||||
})()
|
||||
Loading…
Reference in a new issue