ログ更新の自動追従を追加 (ON/OFF 切替つき)

ヘッダの「自動追従」で有効/無効を切り替える。状態は localStorage に保持し、
既定は有効。無効時は従来どおり再読込ボタンで更新する。

- Watcher スレッド: --interval 秒ごとに jsonl を stat し、ファイル数・合計
  サイズ・最新 mtime の組が変わったら rev を進めて待機中の SSE を起こす
- /api/watch (SSE) で change を通知、20 秒ごとに ping で接続維持。
  /api/rev で現在の版も取れる
- ブラウザ側は通知を 1.2 秒まとめてから表示中のものだけ黙って作り直す。
  プロジェクト/タスクの選択・スクロール位置・絞り込み・タブは維持し、
  やりとりを末尾まで見ていた場合は追記行に追従する
- 日別集計・ガントの更新は placeholder を出さない silent 経路を追加
- 後から有効にした時に、無効だった間の変更を版の比較で取り込む

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 11:21:05 +09:00
co-authored by Claude Opus 5
parent 0205404797
commit 0d9faa54bb
5 changed files with 273 additions and 19 deletions
+145 -18
View File
@@ -15,6 +15,11 @@ const state = {
daily: null,
ganttDays: 7,
gantt: null,
follow: false,
es: null,
rev: null,
followTimer: null,
refreshing: false,
};
const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca'];
@@ -87,7 +92,7 @@ async function api(path) {
// ---------------------------------------------------------------- プロジェクト
async function loadProjects() {
async function loadProjects({ select = true } = {}) {
const data = await api('/api/projects');
state.projects = data.projects;
$('#root').textContent = data.root;
@@ -113,26 +118,37 @@ async function loadProjects() {
ul.appendChild(li);
});
// 再読込では選択を保つ (null = 横断モードもそのまま)
// 再読込・自動追従では選択を保つ (null = 横断モードもそのまま)
if (state.project === undefined) state.project = data.projects.length ? data.projects[0].id : null;
else if (state.project && !data.projects.some((p) => p.id === state.project)) state.project = null;
selectProject(state.project);
markProject();
if (select) selectProject(state.project);
}
function markProject() {
document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (state.project ?? '')));
$('#csv-sessions').href = state.project
? `/api/sessions?project=${encodeURIComponent(state.project)}&format=csv`
: '/api/all?format=csv';
}
async function selectProject(id) {
state.project = id;
document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (id ?? '')));
$('#csv-sessions').href = id ? `/api/sessions?project=${encodeURIComponent(id)}&format=csv` : '/api/all?format=csv';
const tbody = $('#session-table tbody');
tbody.innerHTML = '<tr><td class="loading">解析中…</td></tr>';
markProject();
$('#session-table tbody').innerHTML = '<tr><td class="loading">解析中…</td></tr>';
if (state.view === 'daily') loadDaily();
else if (state.view === 'gantt') loadGantt();
await reloadSessions();
}
async function reloadSessions() {
const id = state.project;
try {
const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all');
state.sessions = data.sessions;
renderSessions();
} catch (e) {
tbody.innerHTML = `<tr><td class="err">${esc(e.message)}</td></tr>`;
$('#session-table tbody').innerHTML = `<tr><td class="err">${esc(e.message)}</td></tr>`;
}
}
@@ -186,8 +202,15 @@ async function selectSession(project, id) {
document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id));
updateCsv();
$('#detail-body').innerHTML = '<p class="loading">読み込み中…</p>';
await reloadDetail();
}
async function reloadDetail() {
if (!state.session) return renderDetail();
try {
state.detail = await api(`/api/session?project=${encodeURIComponent(project)}&id=${encodeURIComponent(id)}`);
state.detail = await api(
`/api/session?project=${encodeURIComponent(state.sessionProject)}&id=${encodeURIComponent(state.session)}`
);
renderDetail();
} catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
@@ -319,9 +342,11 @@ function setView(view) {
else renderDetail();
}
async function loadDaily() {
$('#detail-title').textContent = '日別集計';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
async function loadDaily({ silent = false } = {}) {
if (!silent) {
$('#detail-title').textContent = '日別集計';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
}
try {
state.daily = await api('/api/daily?' + dailyQuery());
renderDaily();
@@ -488,9 +513,11 @@ function renderDaily() {
// ---------------------------------------------------------------- ガント
async function loadGantt() {
$('#detail-title').textContent = 'ガント';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
async function loadGantt({ silent = false } = {}) {
if (!silent) {
$('#detail-title').textContent = 'ガント';
$('#detail-body').innerHTML = '<p class="loading">集計中…</p>';
}
try {
state.gantt = await api('/api/timeline?' + ganttQuery());
renderGantt();
@@ -680,6 +707,95 @@ function wireRanges(key, reload) {
});
}
// ---------------------------------------------------------------- 自動追従
const FOLLOW_KEY = 'chktime.follow';
const pad2 = (n) => String(n).padStart(2, '0');
function followStatus(kind, text) {
const el = $('#follow-state');
el.className = 'fstate ' + kind;
el.textContent = text ?? '';
}
function nowHms() {
const d = new Date();
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
/** ログが変わったとき、表示中のものだけを黙って作り直す (選択・スクロール位置は保つ)。 */
async function refreshFollow() {
if (state.refreshing) return;
state.refreshing = true;
const box = $('#detail-body');
const list = $('#sessions .scroll');
const boxTop = box.scrollTop;
const listTop = list.scrollTop;
const atBottom = box.scrollHeight - box.clientHeight - boxTop < 40;
try {
await loadProjects({ select: false });
await reloadSessions();
if (state.view === 'daily') await loadDaily({ silent: true });
else if (state.view === 'gantt') await loadGantt({ silent: true });
else await reloadDetail();
list.scrollTop = listTop;
// やりとりを末尾まで見ていたら、追記された行に追従する (tail -f と同じ感覚)
box.scrollTop = atBottom && state.view === 'turns' ? box.scrollHeight : boxTop;
followStatus('live', `更新 ${nowHms()}`);
} catch (e) {
followStatus('err', esc(e.message).slice(0, 40));
} finally {
state.refreshing = false;
}
}
function scheduleRefresh() {
// 書き込み中は数秒おきに飛んでくるので、まとめてから読み直す
followStatus('busy', '更新あり…');
clearTimeout(state.followTimer);
state.followTimer = setTimeout(refreshFollow, 1200);
}
function revOf(event) {
try {
return JSON.parse(event.data).rev;
} catch {
return null;
}
}
function startFollow() {
if (state.es) return;
state.follow = true;
followStatus('wait', '接続中…');
const es = new EventSource('/api/watch');
es.onopen = () => followStatus('live', '追従中');
es.addEventListener('hello', (e) => {
const rev = revOf(e);
// OFF の間や切断中の変更を取り込む (初回接続時は読み込み済みなので不要)
const stale = state.rev !== null && rev !== state.rev;
state.rev = rev;
followStatus('live', '追従中');
if (stale) scheduleRefresh();
});
es.addEventListener('change', (e) => {
state.rev = revOf(e);
scheduleRefresh();
});
es.onerror = () => followStatus('err', '切断(再接続待ち)');
state.es = es;
}
function stopFollow() {
state.follow = false;
clearTimeout(state.followTimer);
if (state.es) {
state.es.close();
state.es = null;
}
followStatus('off', '');
}
// ---------------------------------------------------------------- 起動
$('#filter').oninput = renderSessions;
@@ -707,6 +823,17 @@ window.addEventListener('resize', () => {
}, 150);
});
loadProjects().catch((e) => {
$('#project-list').innerHTML = `<li class="err">${esc(e.message)}</li>`;
});
$('#follow').checked = localStorage.getItem(FOLLOW_KEY) !== '0'; // 既定は ON
$('#follow').onchange = (e) => {
localStorage.setItem(FOLLOW_KEY, e.target.checked ? '1' : '0');
if (e.target.checked) startFollow();
else stopFollow();
};
loadProjects()
.then(() => {
if ($('#follow').checked) startFollow();
})
.catch((e) => {
$('#project-list').innerHTML = `<li class="err">${esc(e.message)}</li>`;
});