Files
playchat/frontend/src/components/LogViewer.vue
T
colcaroliandClaude Sonnet 4.6 4a08ba29fa Add system memory monitoring, Bedrock support, and Discord integrations
- Add CPU/GPU memory monitoring endpoint and frontend widget
- Add Amazon Bedrock model support (API key / inference profiles) plus model selector entries
- Add Discord weather/laundry notification job (JMA forecast + LLM-generated message)
- Add Discord bot that replies to mentions via the configured LLM, with weather-aware context
- Add scheduled "useless tips" job with separate webhook and quiet-hours skip
- Switch FastAPI startup to lifespan handler, log viewer, markdown rendering, and toast UI additions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 00:59:38 +09:00

177 lines
5.8 KiB
Vue

<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { fetchLogs, deleteLog, type ApiLog } from '../api/client'
import { useChatStore } from '../stores/chat'
const store = useChatStore()
const logs = ref<ApiLog[]>([])
const filterStatus = ref<'all' | 'ok' | 'error'>('all')
const filterSession = ref(false)
const expanded = ref<Set<number>>(new Set())
const loading = ref(false)
const error = ref<string | null>(null)
async function load() {
loading.value = true
error.value = null
try {
logs.value = await fetchLogs({
status: filterStatus.value === 'all' ? undefined : filterStatus.value,
session_id: filterSession.value && store.currentSessionId ? store.currentSessionId : undefined,
limit: 200,
})
} catch (e) {
error.value = e instanceof Error ? e.message : 'エラーが発生しました'
} finally {
loading.value = false
}
}
async function remove(id: number) {
await deleteLog(id)
logs.value = logs.value.filter((l) => l.id !== id)
}
function toggle(id: number) {
if (expanded.value.has(id)) expanded.value.delete(id)
else expanded.value.add(id)
}
function formatTime(iso: string) {
return new Date(iso).toLocaleString('ja-JP', { hour12: false })
}
function requestMessages(log: ApiLog): string {
try {
const msgs: { role: string; content: string }[] = JSON.parse(log.request_json)
return msgs.map((m) => `[${m.role}] ${m.content}`).join('\n\n')
} catch {
return log.request_json
}
}
watch([filterStatus, filterSession], load)
onMounted(load)
</script>
<template>
<div class="log-viewer">
<div class="toolbar">
<span class="title">API ログ</span>
<div class="filters">
<label>
<input type="checkbox" v-model="filterSession" :disabled="!store.currentSessionId" />
現在のセッションのみ
</label>
<select v-model="filterStatus">
<option value="all">すべて</option>
<option value="ok">成功のみ</option>
<option value="error">エラーのみ</option>
</select>
</div>
<button class="reload-btn" @click="load" :disabled="loading">↻</button>
</div>
<div v-if="loading" class="empty">読み込み中...</div>
<div v-else-if="error" class="empty api-error">{{ error }}</div>
<div v-else-if="logs.length === 0" class="empty">ログがありません</div>
<ul v-else>
<li v-for="log in logs" :key="log.id" :class="{ 'has-error': !!log.error }">
<div class="log-header" @click="toggle(log.id)">
<span class="status-dot" :class="log.error ? 'err' : 'ok'" />
<span class="model">{{ log.model }}</span>
<span class="latency">{{ log.latency_ms != null ? log.latency_ms + ' ms' : '-' }}</span>
<span class="time">{{ formatTime(log.created_at) }}</span>
<button class="del-btn" @click.stop="remove(log.id)">✕</button>
<span class="arrow">{{ expanded.has(log.id) ? '▲' : '▼' }}</span>
</div>
<div v-if="expanded.has(log.id)" class="log-detail">
<section>
<h4>リクエスト</h4>
<pre>{{ requestMessages(log) }}</pre>
</section>
<section v-if="log.response_text">
<h4>レスポンス</h4>
<pre>{{ log.response_text }}</pre>
</section>
<section v-if="log.error" class="error-section">
<h4>エラー</h4>
<pre>{{ log.error }}</pre>
</section>
</div>
</li>
</ul>
</div>
</template>
<style scoped>
.log-viewer {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: #111;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
}
.title { font-size: 13px; font-weight: 600; color: #aaa; flex: 1; }
.filters { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #888; }
.filters label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
.filters input { accent-color: #2563eb; cursor: pointer; }
select {
background: #2a2a2a; color: #ccc; border: 1px solid #444;
border-radius: 5px; padding: 2px 6px; font-size: 12px;
}
.reload-btn {
background: none; border: 1px solid #444; color: #aaa;
border-radius: 5px; padding: 2px 8px; cursor: pointer; font-size: 14px;
}
.reload-btn:hover:not(:disabled) { color: #fff; }
.empty { padding: 20px; text-align: center; color: #555; font-size: 13px; }
.api-error { color: #f87171 !important; }
ul { list-style: none; margin: 0; padding: 4px 0; overflow-y: auto; flex: 1; }
li { border-bottom: 1px solid #1e1e1e; }
li.has-error { border-left: 3px solid #7f1d1d; }
.log-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; cursor: pointer;
font-size: 12px;
}
.log-header:hover { background: #1a1a1a; }
.status-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.status-dot.ok { background: #22c55e; }
.status-dot.err { background: #ef4444; }
.model { color: #ccc; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.latency { color: #666; width: 60px; text-align: right; }
.time { color: #555; white-space: nowrap; }
.del-btn {
background: none; border: none; color: #555;
cursor: pointer; font-size: 11px; opacity: 0;
}
.log-header:hover .del-btn { opacity: 1; }
.arrow { color: #555; font-size: 10px; }
.log-detail {
padding: 10px 14px 14px;
display: flex; flex-direction: column; gap: 10px;
}
section h4 { font-size: 11px; color: #666; margin-bottom: 4px; text-transform: uppercase; }
pre {
background: #1a1a1a; color: #ccc; border-radius: 6px;
padding: 10px; font-size: 12px; line-height: 1.5;
white-space: pre-wrap; word-break: break-word; margin: 0;
max-height: 300px; overflow-y: auto;
}
.error-section pre { background: #1f0a0a; color: #f87171; }
</style>