LoopLogView.vue 3.71 KB
<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>