LoopLogView.vue
3.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
<template>
<div class="loops-view">
<div class="bar">
<span>Total: {{ events.length }} events</span>
<span class="grow" />
<label>Filter:
<input v-model="filterText" placeholder="kind / summary substring" />
</label>
<button class="btn" @click="reload">Refresh</button>
</div>
<section v-for="(grp, key) in groups" :key="key" class="group">
<h3>{{ key }} <span class="count">({{ grp.length }})</span></h3>
<ul>
<li v-for="e in grp" :key="e.ts + e.kind">
<span class="ts">{{ formatTs(e.ts) }}</span>
<span :class="['kind', e.kind.replace(/\./g, '-')]">{{ e.kind }}</span>
<span class="src">[{{ e.source || 'system' }}]</span>
<span class="summary">{{ e.summary }}</span>
</li>
</ul>
</section>
<p v-if="!events.length" class="empty">no timeline events</p>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
const props = defineProps({ pid: { type: String, required: true } });
const events = ref([]);
const filterText = ref('');
async function reload() {
const r = await fetch(`/api/devloop/projects/${props.pid}/timeline?limit=500`);
if (!r.ok) return;
const j = await r.json();
events.value = j.events || [];
}
const filtered = computed(() => {
const q = filterText.value.toLowerCase().trim();
if (!q) return events.value;
return events.value.filter(e =>
(e.kind || '').toLowerCase().includes(q) ||
(e.summary || '').toLowerCase().includes(q));
});
const groups = computed(() => {
const out = { 'L1 (unit)': [], 'L2 (integration / triage)': [], 'L3 (human / status)': [], 'System / Docs / Git': [] };
for (const e of filtered.value) {
const k = e.kind || '';
if (/^unit\.|^L1/.test(k)) out['L1 (unit)'].push(e);
else if (/integration|triage|sdd\.|units\.reset|l2_/.test(k)) out['L2 (integration / triage)'].push(e);
else if (/^project\.status|^project\.shipped|^project\.abandoned|human_intervention|prd\.updated/.test(k)) out['L3 (human / status)'].push(e);
else out['System / Docs / Git'].push(e);
}
return out;
});
function formatTs(ts) {
if (!ts) return '-';
try { return new Date(ts).toLocaleTimeString(); } catch { return String(ts); }
}
onMounted(reload);
</script>
<style scoped>
.loops-view {}
.bar { display:flex; align-items:center; gap:14px; margin-bottom:12px; font-size:13px; }
.bar .grow { flex:1; }
.bar input { padding:4px 8px; border:1px solid #ccc; border-radius:4px; width:200px; }
.btn { background:#222; color:#fff; border:none; border-radius:4px; padding:4px 12px; cursor:pointer; font-size:12px; }
.group { margin-bottom:18px; }
.group h3 { font-size:12px; color:#888; margin:0 0 6px; text-transform:uppercase; letter-spacing:0.5px; }
.group h3 .count { color:#bbb; font-weight:400; }
ul { list-style:none; padding:0; margin:0; }
li {
display:flex; gap:8px; padding:5px 8px; font-size:12px;
border-bottom:1px solid #f5f5f7;
}
li:hover { background:#fafafa; }
.ts { color:#888; flex:0 0 100px; font-family: ui-monospace, monospace; }
.kind {
flex:0 0 auto; font-family: ui-monospace, monospace; font-size:11px;
padding:1px 6px; border-radius:4px; background:#eef; color:#446;
}
.kind.unit-state { background:#cce5ff; color:#004085; }
.kind.integration-result { background:#ffeede; color:#a64a00; }
.kind.triage-result { background:#fde6f5; color:#8b1d72; }
.kind.sdd-updated, .kind.prd-updated { background:#ffd; color:#7a6b00; }
.kind.project-shipped { background:#d4edda; color:#155724; }
.kind.project-status { background:#eee; color:#444; }
.src { color:#999; font-size:11px; flex:0 0 auto; }
.summary { color:#444; flex:1; overflow:hidden; text-overflow:ellipsis; }
.empty { padding:30px; text-align:center; color:#888; }
</style>