ログ更新の自動追従を追加 (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:
@@ -29,6 +29,7 @@ python chktime.py
|
||||
| `--port` | 8787 | ポート番号 |
|
||||
| `--host` | 127.0.0.1 | 待ち受けアドレス |
|
||||
| `--dir` | `~/.claude/projects` | ログの置き場所 |
|
||||
| `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 |
|
||||
| `--no-browser` | – | ブラウザを自動で開かない |
|
||||
|
||||
## 画面
|
||||
@@ -75,13 +76,24 @@ python chktime.py
|
||||
`CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。
|
||||
横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。
|
||||
|
||||
## 自動追従
|
||||
|
||||
ヘッダの **自動追従** をオンにすると、ログが増えるたびに表示を作り直します。Claude Code を
|
||||
動かしながら開いておく用途向けです。オン/オフの状態はブラウザに記憶されます(既定はオン)。
|
||||
|
||||
- サーバが `--interval` 秒ごとに jsonl を stat して変化を検出し、SSE(`/api/watch`)でブラウザに通知します。ブラウザ側は 1.2 秒まとめてから読み直します。
|
||||
- 更新時も**選択中のプロジェクト・タスク・スクロール位置・絞り込み・開いているタブは保たれます**。やりとりを末尾まで見ていた場合は、追記された行に追従して末尾に留まります。
|
||||
- ヘッダ右の表示が状態です。`● 追従中` → 変化を検出すると `● 更新あり…` → 反映後に `● 更新 hh:mm:ss`。切断されると `● 切断(再接続待ち)` となり、自動で再接続します。
|
||||
- オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。
|
||||
- オフのときは従来どおり **再読込** ボタンで更新します(解析キャッシュも捨てる強制再読込)。
|
||||
|
||||
## 解析の仕様(ざっくり)
|
||||
|
||||
- ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。
|
||||
- `/model` などのスラッシュコマンドは `command`、コンパクト後の自動継続は `resume` とラベル表示します。
|
||||
- `[Request interrupted...]`(Esc 中断)が含まれるターンには ⚠ を付けます。
|
||||
- セッション再開時に書かれるメタ行(attachment / system / mode 等)は、ターンの所要時間には数えません。
|
||||
- 解析結果はファイルの更新時刻+サイズでキャッシュします。ログを更新したらヘッダの **再読込** を押してください。
|
||||
- 解析結果はファイルの更新時刻+サイズでキャッシュします。変わったファイルだけ再解析するので、自動追従がオンなら手動操作は不要です。
|
||||
|
||||
## ファイル
|
||||
|
||||
|
||||
+99
@@ -21,6 +21,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -586,6 +587,72 @@ class Store:
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
class Watcher(threading.Thread):
|
||||
"""projects 配下の jsonl を定期的に stat して変更を検出する (自動追従の通知用)。
|
||||
|
||||
ファイル数・合計サイズ・最新 mtime の組を「版」として持ち、変わったら rev を進めて
|
||||
待っているクライアント (SSE) を起こす。解析結果は Store 側が (mtime, size) で
|
||||
キャッシュしているので、ここでは通知だけを担う。
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path, interval=2.0):
|
||||
super().__init__(daemon=True)
|
||||
self.root = root
|
||||
self.interval = max(0.5, interval)
|
||||
self.rev = 0
|
||||
self.newest = None # (project_id, session_id)
|
||||
self._cv = threading.Condition()
|
||||
self._token, self.newest = self._scan()
|
||||
|
||||
def _scan(self):
|
||||
files = 0
|
||||
total = 0
|
||||
newest_mtime = 0.0
|
||||
newest = None
|
||||
if self.root.is_dir():
|
||||
for entry in self.root.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
for path in entry.glob("*.jsonl"):
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
files += 1
|
||||
total += stat.st_size
|
||||
if stat.st_mtime > newest_mtime:
|
||||
newest_mtime = stat.st_mtime
|
||||
newest = (entry.name, path.stem)
|
||||
return (files, total, round(newest_mtime, 3)), newest
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
time.sleep(self.interval)
|
||||
try:
|
||||
token, newest = self._scan()
|
||||
except OSError:
|
||||
continue
|
||||
with self._cv:
|
||||
if token != self._token:
|
||||
self._token = token
|
||||
self.newest = newest
|
||||
self.rev += 1
|
||||
self._cv.notify_all()
|
||||
|
||||
def wait_change(self, rev, timeout):
|
||||
"""rev より進むまで待って、現在の rev を返す (タイムアウト時は同じ値)。"""
|
||||
with self._cv:
|
||||
if self.rev != rev:
|
||||
return self.rev
|
||||
self._cv.wait(timeout)
|
||||
return self.rev
|
||||
|
||||
def info(self):
|
||||
with self._cv:
|
||||
project, session = self.newest or (None, None)
|
||||
return {"rev": self.rev, "project": project, "session": session, "interval": self.interval}
|
||||
|
||||
|
||||
CWD_RE = re.compile(rb'"cwd":"((?:[^"\\]|\\.)*)"')
|
||||
|
||||
|
||||
@@ -750,8 +817,37 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if route == "/api/reload":
|
||||
store.clear()
|
||||
return self.send_json({"ok": True})
|
||||
if route == "/api/rev":
|
||||
return self.send_json(self.server.watcher.info())
|
||||
if route == "/api/watch":
|
||||
return self.watch()
|
||||
raise FileNotFoundError(route)
|
||||
|
||||
def watch(self):
|
||||
"""SSE。ログに変更があるまでブロックし、あれば change イベントを流す。"""
|
||||
watcher = self.server.watcher
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self.end_headers()
|
||||
rev = watcher.rev
|
||||
try:
|
||||
self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n")
|
||||
while True:
|
||||
current = watcher.wait_change(rev, 20)
|
||||
if current != rev:
|
||||
rev = current
|
||||
self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n")
|
||||
else:
|
||||
self.stream(": ping\n\n") # 接続維持
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError):
|
||||
return # タブを閉じた等
|
||||
|
||||
def stream(self, text):
|
||||
self.wfile.write(text.encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
|
||||
def static(self, route):
|
||||
name = "index.html" if route in ("/", "") else route.lstrip("/")
|
||||
target = (WEB_DIR / name).resolve()
|
||||
@@ -793,6 +889,7 @@ def main():
|
||||
parser.add_argument("--port", type=int, default=8787)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ")
|
||||
parser.add_argument("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)")
|
||||
parser.add_argument("--no-browser", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -812,6 +909,8 @@ def main():
|
||||
webbrowser.open(url)
|
||||
return
|
||||
server.store = Store(root)
|
||||
server.watcher = Watcher(root, args.interval)
|
||||
server.watcher.start()
|
||||
print(f"chktime: {root}")
|
||||
print(f" -> {url} (Ctrl+C で終了)")
|
||||
if not args.no_browser:
|
||||
|
||||
+140
-13
@@ -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() {
|
||||
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() {
|
||||
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) => {
|
||||
$('#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>`;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<span class="sub" id="root"></span>
|
||||
<span class="spacer"></span>
|
||||
<label class="chk"><input type="checkbox" id="relative"> 経過時間で表示</label>
|
||||
<label class="chk"><input type="checkbox" id="follow"> 自動追従</label>
|
||||
<span id="follow-state" class="fstate"></span>
|
||||
<button id="reload" title="キャッシュを破棄して再読込">再読込</button>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -39,6 +39,20 @@ header .sub { color: var(--dim); font-size: 11px; }
|
||||
header .spacer { flex: 1; }
|
||||
.chk { color: var(--dim); user-select: none; cursor: pointer; }
|
||||
|
||||
/* 自動追従の状態表示 */
|
||||
.fstate { color: var(--dim); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.fstate:not(.off):not(:empty)::before {
|
||||
content: "●";
|
||||
font-size: 9px;
|
||||
margin-right: 4px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.fstate.live::before { color: var(--work); }
|
||||
.fstate.busy::before { color: var(--accent); }
|
||||
.fstate.wait::before { color: var(--warn); }
|
||||
.fstate.err { color: var(--warn); }
|
||||
.fstate.err::before { color: var(--warn); }
|
||||
|
||||
button, .btn {
|
||||
background: var(--pane2);
|
||||
color: var(--fg);
|
||||
|
||||
Reference in New Issue
Block a user