Module: TempmailSdk::WebUI

Defined in:
lib/tempmail_sdk/webui.rb

Overview

嵌入式 WebUI HTTP 服务器 通过 TEMPMAIL_WEBUI=true/1/yes 激活,TEMPMAIL_WEBUI_PORT 指定端口(未设置则随机) 仅依赖 Ruby 标准库,不引入任何外部 gem

Constant Summary collapse

HTML_TEMPLATE =

rubocop:disable Layout/LineLength

<<~'HTML'
  <!DOCTYPE html>
  <html lang="zh-CN">
  <head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>TempMail SDK - WebUI</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
  <style>
  [v-cloak]{display:none}
  .log-debug{color:#6b7280}.log-info{color:#3b82f6}.log-warn{color:#f59e0b}.log-error{color:#ef4444}
  .fade-enter-active{transition:opacity .3s}.fade-enter-from{opacity:0}
  #log-container{max-height:60vh;overflow-y:auto;scroll-behavior:smooth}
  </style>
  </head>
  <body class="bg-gray-900 text-gray-100 min-h-screen">
  <div id="app" v-cloak>
    <header class="bg-gray-800 border-b border-gray-700 px-6 py-4 flex items-center justify-between">
      <div class="flex items-center gap-3">
        <h1 class="text-xl font-bold">TempMail SDK</h1>
        <span class="text-sm text-gray-400">{{ sdkInfo.language }} · v{{ sdkInfo.version }}</span>
      </div>
      <div class="flex items-center gap-4 text-sm">
        <span class="px-2 py-1 rounded" :class="connected ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'">
          {{ connected ? '已连接' : '已断开' }}
        </span>
        <span class="text-gray-400">端口 {{ sdkInfo.port }}</span>
      </div>
    </header>
    <nav class="bg-gray-800 border-b border-gray-700 px-6">
      <div class="flex gap-1">
        <button v-for="tab in tabs" :key="tab.id" @click="activeTab=tab.id"
          class="px-4 py-2 text-sm rounded-t transition-colors"
          :class="activeTab===tab.id ? 'bg-gray-900 text-white' : 'text-gray-400 hover:text-gray-200'">
          {{ tab.label }}
        </button>
      </div>
    </nav>
    <main class="p-6">
      <section v-show="activeTab==='overview'">
        <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
          <div class="bg-gray-800 rounded-lg p-4">
            <div class="text-gray-400 text-sm">渠道总数</div>
            <div class="text-2xl font-bold">{{ channels.length }}</div>
          </div>
          <div class="bg-gray-800 rounded-lg p-4">
            <div class="text-gray-400 text-sm">代理</div>
            <div class="text-lg font-mono">{{ sdkInfo.proxy || '未设置' }}</div>
          </div>
          <div class="bg-gray-800 rounded-lg p-4">
            <div class="text-gray-400 text-sm">超时</div>
            <div class="text-lg">{{ sdkInfo.timeout }}s</div>
          </div>
        </div>
        <div class="bg-gray-800 rounded-lg p-4">
          <h3 class="text-sm text-gray-400 mb-2">配置</h3>
          <pre class="text-xs font-mono text-gray-300 whitespace-pre-wrap">{{ JSON.stringify(sdkInfo.config, null, 2) }}</pre>
        </div>
      </section>
      <section v-show="activeTab==='channels'">
        <div class="mb-4">
          <input v-model="channelSearch" type="text" placeholder="搜索渠道..."
            class="bg-gray-800 border border-gray-600 rounded px-3 py-2 text-sm w-64 focus:outline-none focus:border-blue-500">
        </div>
        <div class="bg-gray-800 rounded-lg overflow-hidden">
          <table class="w-full text-sm">
            <thead class="bg-gray-750">
              <tr class="border-b border-gray-700">
                <th class="px-4 py-2 text-left text-gray-400">#</th>
                <th class="px-4 py-2 text-left text-gray-400">渠道标识</th>
                <th class="px-4 py-2 text-left text-gray-400">名称</th>
                <th class="px-4 py-2 text-left text-gray-400">网站</th>
              </tr>
            </thead>
            <tbody>
              <tr v-for="(ch, i) in filteredChannels" :key="ch.channel"
                class="border-b border-gray-700/50 hover:bg-gray-700/30">
                <td class="px-4 py-2 text-gray-500">{{ i + 1 }}</td>
                <td class="px-4 py-2 font-mono">{{ ch.channel }}</td>
                <td class="px-4 py-2">{{ ch.name }}</td>
                <td class="px-4 py-2"><a :href="'https://'+ch.website" target="_blank" class="text-blue-400 hover:underline">{{ ch.website }}</a></td>
              </tr>
            </tbody>
          </table>
        </div>
      </section>
      <section v-show="activeTab==='logs'">
        <div class="flex items-center gap-3 mb-4">
          <select v-model="logLevel" class="bg-gray-800 border border-gray-600 rounded px-2 py-1 text-sm">
            <option value="all">全部</option>
            <option value="debug">Debug</option>
            <option value="info">Info</option>
            <option value="warn">Warn</option>
            <option value="error">Error</option>
          </select>
          <button @click="logs=[]" class="text-sm text-gray-400 hover:text-white">清空</button>
          <label class="text-sm text-gray-400 flex items-center gap-1">
            <input type="checkbox" v-model="autoScroll"> 自动滚动
          </label>
          <span class="text-xs text-gray-500 ml-auto">{{ logs.length }} 条</span>
        </div>
        <div id="log-container" ref="logContainer" class="bg-gray-950 rounded-lg p-4 font-mono text-xs">
          <div v-for="(log, i) in filteredLogs" :key="i" class="py-0.5" :class="'log-'+log.level">
            <span class="text-gray-600">{{ log.time }}</span>
            <span class="px-1">[{{ log.level.toUpperCase() }}]</span>
            <span v-if="log.channel" class="text-purple-400">[{{ log.channel }}]</span>
            {{ log.msg }}
          </div>
          <div v-if="filteredLogs.length===0" class="text-gray-600">暂无日志</div>
        </div>
      </section>
    </main>
  </div>
  <!-- PLACEHOLDER_SCRIPT -->
  </body>
  </html>
HTML
APP_SCRIPT =
<<~'JS'
  <script>
  const {createApp, ref, computed, onMounted, onUnmounted, nextTick} = Vue
  createApp({
    setup() {
      const tabs = [{id:'overview',label:'概览'},{id:'channels',label:'渠道'},{id:'logs',label:'日志'}]
      const activeTab = ref('overview')
      const connected = ref(false)
      const sdkInfo = ref({language:'',version:'',port:'',proxy:'',timeout:30,config:{}})
      const channels = ref([])
      const logs = ref([])
      const channelSearch = ref('')
      const logLevel = ref('all')
      const autoScroll = ref(true)
      const logContainer = ref(null)
      let evtSource = null
      const filteredChannels = computed(() => {
        if (!channelSearch.value) return channels.value
        const q = channelSearch.value.toLowerCase()
        return channels.value.filter(c =>
          c.channel.includes(q) || c.name.toLowerCase().includes(q) || c.website.includes(q))
      })
      const filteredLogs = computed(() => {
        if (logLevel.value === 'all') return logs.value
        return logs.value.filter(l => l.level === logLevel.value)
      })
      async function fetchInfo() {
        try { const r = await fetch('/api/info'); if (r.ok) sdkInfo.value = await r.json() } catch(e) {}
      }
      async function fetchChannels() {
        try { const r = await fetch('/api/channels'); if (r.ok) channels.value = await r.json() } catch(e) {}
      }
      function connectSSE() {
        evtSource = new EventSource('/api/logs/stream')
        evtSource.onopen = () => { connected.value = true }
        evtSource.onmessage = (e) => {
          try {
            const entry = JSON.parse(e.data)
            logs.value.push(entry)
            if (logs.value.length > 5000) logs.value.splice(0, 500)
            if (autoScroll.value) nextTick(() => { const el = logContainer.value; if (el) el.scrollTop = el.scrollHeight })
          } catch(err) {}
        }
        evtSource.onerror = () => { connected.value = false; setTimeout(connectSSE, 3000) }
      }
      onMounted(() => { fetchInfo(); fetchChannels(); connectSSE() })
      onUnmounted(() => { if (evtSource) evtSource.close() })
      return {tabs,activeTab,connected,sdkInfo,channels,logs,channelSearch,logLevel,
              autoScroll,logContainer,filteredChannels,filteredLogs}
    }
  }).mount('#app')
  </script>
JS

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.hostObject (readonly)

Returns the value of attribute host.



22
23
24
# File 'lib/tempmail_sdk/webui.rb', line 22

def host
  @host
end

.portObject (readonly)

Returns the value of attribute port.



22
23
24
# File 'lib/tempmail_sdk/webui.rb', line 22

def port
  @port
end

Class Method Details

.auto_startObject

自动检查环境变量并启动



180
181
182
183
# File 'lib/tempmail_sdk/webui.rb', line 180

def auto_start
  flag = (ENV["TEMPMAIL_WEBUI"] || "").strip.downcase
  WebUI.start if %w[1 true yes on].include?(flag)
end

.broadcast(entry) ⇒ Object

广播日志条目到所有 SSE 连接



45
46
47
48
49
50
# File 'lib/tempmail_sdk/webui.rb', line 45

def broadcast(entry)
  data = JSON.generate(entry)
  @sse_mutex.synchronize { @sse_clients.dup }.each do |q|
    q.push(data) rescue nil
  end
end

.start(host: nil, port: nil) ⇒ Object

启动 WebUI(幂等)



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/tempmail_sdk/webui.rb', line 25

def start(host: nil, port: nil)
  @server_mutex.synchronize do
    return @port if @server

    bind_host = host || _resolve_host
    bind_port = port || _resolve_port
    @server = TCPServer.new(bind_host, bind_port)
    @host   = bind_host
    @port   = @server.addr[1]

    # 挂接日志处理器
    _attach_log_handler

    Thread.new { _serve }
    Log.logger.info("WebUI 已启动: http://#{@host}:#{@port}")
    @port
  end
end