feat(posimai-dev): add sessions viewer, chat bar, Claude button, session logging
- sessions.html: ANSI-stripped log viewer with card list, clickable to expand - index.html: chat input bar (mobile-friendly), Claude 開始 button, session badge, glassmorphism header - server.js: session logging to ~/posimai-dev-sessions/, auto-cd to posimai-project, sessions REST API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7d6f40e2b7
commit
1a00108255
|
|
@ -13,8 +13,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<meta name="description" content="posimai development portal">
|
<meta name="description" content="posimai development portal">
|
||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
<meta name="theme-color" content="#0D0D0D" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#0C1221">
|
||||||
<meta name="theme-color" content="#F9FAFB" media="(prefers-color-scheme: light)">
|
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
|
@ -38,10 +37,7 @@
|
||||||
--accent-dim: rgba(167, 139, 250, 0.15);
|
--accent-dim: rgba(167, 139, 250, 0.15);
|
||||||
--dev-bg: #0C1221;
|
--dev-bg: #0C1221;
|
||||||
}
|
}
|
||||||
[data-theme="light"] {
|
[data-theme="light"] { --accent: #7C3AED; }
|
||||||
--accent: #7C3AED;
|
|
||||||
--dev-bg: #0C1221;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
|
@ -51,91 +47,103 @@
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--dev-bg);
|
background: var(--dev-bg);
|
||||||
|
font-family: Inter, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
background: rgba(12, 18, 33, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
}
|
}
|
||||||
|
.header-left { display: flex; align-items: center; gap: 10px; }
|
||||||
.header-left {
|
.header-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||||
display: flex;
|
.header-title { font-size: 14px; font-weight: 600; color: #F3F4F6; letter-spacing: -0.01em; }
|
||||||
align-items: center;
|
.header-right { display: flex; align-items: center; gap: 4px; }
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
font-size: 11px;
|
font-size: 11px; font-weight: 500; padding: 2px 8px;
|
||||||
font-weight: 500;
|
border-radius: 20px; background: var(--accent-dim); color: var(--accent);
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: var(--accent-dim);
|
|
||||||
color: var(--accent);
|
|
||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
.status-badge.disconnected { background: rgba(239,68,68,0.12); color: #F87171; }
|
||||||
|
|
||||||
.status-badge.disconnected {
|
.session-badge {
|
||||||
background: rgba(239, 68, 68, 0.12);
|
font-size: 10px; font-weight: 400; color: rgba(255,255,255,0.3);
|
||||||
color: #F87171;
|
font-family: monospace; display: none;
|
||||||
}
|
}
|
||||||
|
.session-badge.visible { display: block; }
|
||||||
|
|
||||||
.header-right {
|
/* Claude開始ボタン */
|
||||||
display: flex;
|
.claude-btn {
|
||||||
align-items: center;
|
display: flex; align-items: center; gap: 6px;
|
||||||
gap: 4px;
|
padding: 5px 12px; border-radius: 8px; border: none; cursor: pointer;
|
||||||
|
background: var(--accent-dim); color: var(--accent);
|
||||||
|
font-size: 12px; font-weight: 500; font-family: Inter, sans-serif;
|
||||||
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
.claude-btn:hover { background: rgba(167,139,250,0.25); }
|
||||||
|
|
||||||
|
/* ── Terminal ── */
|
||||||
#terminal-container {
|
#terminal-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 12px;
|
padding: 12px 12px 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse at 15% 60%, rgba(34, 211, 238, 0.07) 0%, transparent 55%),
|
radial-gradient(ellipse at 15% 60%, rgba(34,211,238,0.07) 0%, transparent 55%),
|
||||||
radial-gradient(ellipse at 85% 25%, rgba(167, 139, 250, 0.07) 0%, transparent 55%),
|
radial-gradient(ellipse at 85% 25%, rgba(167,139,250,0.07) 0%, transparent 55%),
|
||||||
var(--dev-bg);
|
var(--dev-bg);
|
||||||
}
|
}
|
||||||
|
#terminal-container .xterm { height: 100%; }
|
||||||
|
#terminal-container .xterm-viewport { border-radius: 8px 8px 0 0; }
|
||||||
|
|
||||||
#terminal-container .xterm {
|
/* ── Chat input bar ── */
|
||||||
height: 100%;
|
.chat-bar {
|
||||||
}
|
|
||||||
|
|
||||||
#terminal-container .xterm-viewport {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* スマホ用キーボード表示ボタン */
|
|
||||||
#keyboard-btn {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
#keyboard-btn {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||||||
|
background: rgba(12, 18, 33, 0.9);
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.chat-input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
color: #F3F4F6;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: Inter, sans-serif;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
.chat-input::placeholder { color: rgba(255,255,255,0.25); }
|
||||||
|
.chat-input:focus { border-color: rgba(167,139,250,0.4); }
|
||||||
|
.send-btn {
|
||||||
|
width: 38px; height: 38px; border-radius: 10px; border: none;
|
||||||
|
background: var(--accent); color: #0C1221; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0; transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.send-btn:hover { opacity: 0.85; }
|
||||||
|
.send-btn:disabled { opacity: 0.3; cursor: default; }
|
||||||
|
|
||||||
.overlay { display: none; }
|
.overlay { display: none; }
|
||||||
.settings-panel { z-index: 1000; }
|
.settings-panel { z-index: 1000; }
|
||||||
|
|
||||||
|
/* icon-btn override for dark bg */
|
||||||
|
.icon-btn { color: rgba(255,255,255,0.5); }
|
||||||
|
.icon-btn:hover { color: #F3F4F6; background: rgba(255,255,255,0.06); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -156,6 +164,13 @@
|
||||||
<button class="theme-btn" data-theme-val="system"><i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動</button>
|
<button class="theme-btn" data-theme-val="system"><i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-top:20px">
|
||||||
|
<div class="settings-group-label">セッション</div>
|
||||||
|
<a href="/sessions.html" style="font-size:13px;color:var(--accent);text-decoration:none;display:flex;align-items:center;gap:6px;margin-top:8px">
|
||||||
|
<i data-lucide="history" style="width:14px;height:14px;stroke-width:1.75"></i>
|
||||||
|
過去のセッションを見る
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="overlay" id="overlay" aria-hidden="true"></div>
|
<div class="overlay" id="overlay" aria-hidden="true"></div>
|
||||||
|
|
@ -165,10 +180,12 @@
|
||||||
<div class="header-dot" aria-hidden="true"></div>
|
<div class="header-dot" aria-hidden="true"></div>
|
||||||
<span class="header-title">posimai-dev</span>
|
<span class="header-title">posimai-dev</span>
|
||||||
<span class="status-badge disconnected" id="statusBadge">接続中...</span>
|
<span class="status-badge disconnected" id="statusBadge">接続中...</span>
|
||||||
|
<span class="session-badge" id="sessionBadge"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button class="icon-btn" id="keyboard-btn" aria-label="キーボードを表示">
|
<button class="claude-btn" id="claudeBtn">
|
||||||
<i data-lucide="keyboard" style="width:18px;height:18px;stroke-width:1.5"></i>
|
<i data-lucide="bot" style="width:13px;height:13px;stroke-width:1.75"></i>
|
||||||
|
Claude 開始
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn" id="settingsBtn" aria-label="設定" aria-expanded="false">
|
<button class="icon-btn" id="settingsBtn" aria-label="設定" aria-expanded="false">
|
||||||
<i data-lucide="settings" style="width:18px;height:18px;stroke-width:1.5"></i>
|
<i data-lucide="settings" style="width:18px;height:18px;stroke-width:1.5"></i>
|
||||||
|
|
@ -178,13 +195,33 @@
|
||||||
|
|
||||||
<div id="terminal-container"></div>
|
<div id="terminal-container"></div>
|
||||||
|
|
||||||
|
<div class="chat-bar">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="chat-input"
|
||||||
|
id="chatInput"
|
||||||
|
placeholder="Claude に話しかける、またはコマンドを入力..."
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
>
|
||||||
|
<button class="send-btn" id="sendBtn" disabled aria-label="送信">
|
||||||
|
<i data-lucide="arrow-up" style="width:16px;height:16px;stroke-width:2.5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
|
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const statusBadge = document.getElementById('statusBadge');
|
const statusBadge = document.getElementById('statusBadge');
|
||||||
|
const sessionBadge = document.getElementById('sessionBadge');
|
||||||
const container = document.getElementById('terminal-container');
|
const container = document.getElementById('terminal-container');
|
||||||
|
const chatInput = document.getElementById('chatInput');
|
||||||
|
const sendBtn = document.getElementById('sendBtn');
|
||||||
|
const claudeBtn = document.getElementById('claudeBtn');
|
||||||
|
|
||||||
// xterm.js setup
|
// xterm.js
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
|
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|
@ -195,55 +232,55 @@
|
||||||
background: 'rgba(12, 18, 33, 0.0)',
|
background: 'rgba(12, 18, 33, 0.0)',
|
||||||
foreground: '#F3F4F6',
|
foreground: '#F3F4F6',
|
||||||
cursor: '#A78BFA',
|
cursor: '#A78BFA',
|
||||||
selectionBackground: 'rgba(167, 139, 250, 0.3)',
|
selectionBackground: 'rgba(167,139,250,0.3)',
|
||||||
black: '#1A1A1A',
|
black: '#1A1A1A', brightBlack: '#4B5563',
|
||||||
brightBlack: '#4B5563',
|
red: '#F87171', brightRed: '#FCA5A5',
|
||||||
red: '#F87171',
|
green: '#6EE7B7', brightGreen: '#A7F3D0',
|
||||||
brightRed: '#FCA5A5',
|
yellow: '#FCD34D',brightYellow: '#FDE68A',
|
||||||
green: '#6EE7B7',
|
blue: '#60A5FA', brightBlue: '#93C5FD',
|
||||||
brightGreen: '#A7F3D0',
|
magenta: '#A78BFA', brightMagenta: '#C4B5FD',
|
||||||
yellow: '#FCD34D',
|
cyan: '#22D3EE', brightCyan: '#67E8F9',
|
||||||
brightYellow: '#FDE68A',
|
white: '#F3F4F6', brightWhite: '#FFFFFF'
|
||||||
blue: '#60A5FA',
|
|
||||||
brightBlue: '#93C5FD',
|
|
||||||
magenta: '#A78BFA',
|
|
||||||
brightMagenta: '#C4B5FD',
|
|
||||||
cyan: '#22D3EE',
|
|
||||||
brightCyan: '#67E8F9',
|
|
||||||
white: '#F3F4F6',
|
|
||||||
brightWhite: '#FFFFFF'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const fitAddon = new FitAddon.FitAddon();
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
term.open(container);
|
term.open(container);
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
|
|
||||||
// WebSocket connection
|
// WebSocket
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
let ws;
|
let ws;
|
||||||
|
|
||||||
|
function sendInput(text) {
|
||||||
|
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data: text }));
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
ws = new WebSocket(`${proto}//${location.host}/terminal`);
|
ws = new WebSocket(`${proto}//${location.host}/terminal`);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
statusBadge.textContent = '接続済み';
|
statusBadge.textContent = '接続済み';
|
||||||
statusBadge.classList.remove('disconnected');
|
statusBadge.classList.remove('disconnected');
|
||||||
const { cols, rows } = term;
|
sendBtn.disabled = false;
|
||||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'output') term.write(msg.data);
|
if (msg.type === 'output') term.write(msg.data);
|
||||||
|
if (msg.type === 'session') {
|
||||||
|
sessionBadge.textContent = msg.id;
|
||||||
|
sessionBadge.classList.add('visible');
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
statusBadge.textContent = '切断';
|
statusBadge.textContent = '切断';
|
||||||
statusBadge.classList.add('disconnected');
|
statusBadge.classList.add('disconnected');
|
||||||
|
sendBtn.disabled = true;
|
||||||
term.write('\r\n\x1b[31m[切断されました。再接続中...]\x1b[0m\r\n');
|
term.write('\r\n\x1b[31m[切断されました。再接続中...]\x1b[0m\r\n');
|
||||||
setTimeout(connect, 3000);
|
setTimeout(connect, 3000);
|
||||||
};
|
};
|
||||||
|
|
@ -253,42 +290,45 @@
|
||||||
|
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
// Terminal input
|
// ターミナル直接入力
|
||||||
term.onData((data) => {
|
term.onData((data) => sendInput(data));
|
||||||
if (ws && ws.readyState === 1) {
|
|
||||||
ws.send(JSON.stringify({ type: 'input', data }));
|
// チャットバー送信
|
||||||
|
function submitChat() {
|
||||||
|
const text = chatInput.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
sendInput(text + '\n');
|
||||||
|
chatInput.value = '';
|
||||||
|
term.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', submitChat);
|
||||||
|
chatInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitChat(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resize handling
|
// Claude 開始ボタン
|
||||||
const ro = new ResizeObserver(() => {
|
claudeBtn.addEventListener('click', () => {
|
||||||
fitAddon.fit();
|
sendInput('claude\n');
|
||||||
if (ws && ws.readyState === 1) {
|
|
||||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
ro.observe(container);
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
fitAddon.fit();
|
|
||||||
});
|
|
||||||
|
|
||||||
// スマホ用キーボードボタン
|
|
||||||
document.getElementById('keyboard-btn').addEventListener('click', () => {
|
|
||||||
term.focus();
|
term.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resize
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||||
|
});
|
||||||
|
ro.observe(container);
|
||||||
|
window.addEventListener('resize', () => fitAddon.fit());
|
||||||
|
|
||||||
// Settings panel
|
// Settings panel
|
||||||
const settingsBtn = document.getElementById('settingsBtn');
|
const settingsBtn = document.getElementById('settingsBtn');
|
||||||
const settingsPanel = document.getElementById('settingsPanel');
|
const settingsPanel = document.getElementById('settingsPanel');
|
||||||
const settingsCloseBtn = document.getElementById('settingsCloseBtn');
|
|
||||||
|
|
||||||
settingsBtn.addEventListener('click', () => {
|
settingsBtn.addEventListener('click', () => {
|
||||||
settingsPanel.classList.toggle('open');
|
settingsPanel.classList.toggle('open');
|
||||||
settingsBtn.setAttribute('aria-expanded', settingsPanel.classList.contains('open'));
|
settingsBtn.setAttribute('aria-expanded', settingsPanel.classList.contains('open'));
|
||||||
});
|
});
|
||||||
|
document.getElementById('settingsCloseBtn').addEventListener('click', () => {
|
||||||
settingsCloseBtn.addEventListener('click', () => {
|
|
||||||
settingsPanel.classList.remove('open');
|
settingsPanel.classList.remove('open');
|
||||||
settingsBtn.setAttribute('aria-expanded', 'false');
|
settingsBtn.setAttribute('aria-expanded', 'false');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,33 @@ const os = require('os');
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3333;
|
const PORT = process.env.PORT || 3333;
|
||||||
|
|
||||||
|
// セッションログ保存ディレクトリ
|
||||||
|
const SESSIONS_DIR = path.join(os.homedir(), 'posimai-dev-sessions');
|
||||||
|
if (!fs.existsSync(SESSIONS_DIR)) fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname)));
|
app.use(express.static(path.join(__dirname)));
|
||||||
|
|
||||||
// ホームディレクトリのTailscale証明書を自動検出
|
// セッション一覧 API
|
||||||
|
app.get('/api/sessions', (req, res) => {
|
||||||
|
const files = fs.readdirSync(SESSIONS_DIR)
|
||||||
|
.filter((f) => f.endsWith('.log'))
|
||||||
|
.map((f) => {
|
||||||
|
const stat = fs.statSync(path.join(SESSIONS_DIR, f));
|
||||||
|
return { id: f.replace('.log', ''), size: stat.size, mtime: stat.mtime };
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
|
||||||
|
res.json(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
// セッション内容 API
|
||||||
|
app.get('/api/sessions/:id', (req, res) => {
|
||||||
|
const file = path.join(SESSIONS_DIR, req.params.id + '.log');
|
||||||
|
if (!fs.existsSync(file)) return res.status(404).json({ error: 'not found' });
|
||||||
|
res.type('text/plain').send(fs.readFileSync(file, 'utf8'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tailscale証明書を自動検出
|
||||||
function findCert() {
|
function findCert() {
|
||||||
const home = os.homedir();
|
const home = os.homedir();
|
||||||
const crt = fs.readdirSync(home).find((f) => f.endsWith('.crt'));
|
const crt = fs.readdirSync(home).find((f) => f.endsWith('.crt'));
|
||||||
|
|
@ -30,30 +54,47 @@ const proto = tlsOpts ? 'https' : 'http';
|
||||||
const wss = new WebSocketServer({ server, path: '/terminal' });
|
const wss = new WebSocketServer({ server, path: '/terminal' });
|
||||||
|
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
|
// セッションID・ログファイル作成
|
||||||
|
const sessionId = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
const logPath = path.join(SESSIONS_DIR, `${sessionId}.log`);
|
||||||
|
const logStream = fs.createWriteStream(logPath, { flags: 'a' });
|
||||||
|
logStream.write(`=== posimai-dev session ${sessionId} ===\n`);
|
||||||
|
|
||||||
|
// セッションID をブラウザに通知
|
||||||
|
ws.send(JSON.stringify({ type: 'session', id: sessionId }));
|
||||||
|
|
||||||
const shell = process.env.SHELL || '/bin/bash';
|
const shell = process.env.SHELL || '/bin/bash';
|
||||||
const ptyProc = pty.spawn(shell, [], {
|
const ptyProc = pty.spawn(shell, [], {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: 80,
|
cols: 80,
|
||||||
rows: 24,
|
rows: 24,
|
||||||
cwd: process.env.HOME || '/home/ubuntu-pc',
|
cwd: path.join(os.homedir(), 'posimai-project'),
|
||||||
env: process.env
|
env: { ...process.env, PATH: `${os.homedir()}/.npm-global/bin:${process.env.PATH}` }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 起動時に posimai-project へ自動移動(cwd で指定済みだが source bashrc も通す)
|
||||||
|
setTimeout(() => ptyProc.write(`source ~/.bashrc\n`), 300);
|
||||||
|
|
||||||
ptyProc.onData((data) => {
|
ptyProc.onData((data) => {
|
||||||
if (ws.readyState === 1) {
|
logStream.write(data);
|
||||||
ws.send(JSON.stringify({ type: 'output', data }));
|
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(raw.toString());
|
const msg = JSON.parse(raw.toString());
|
||||||
if (msg.type === 'input') ptyProc.write(msg.data);
|
if (msg.type === 'input') {
|
||||||
|
logStream.write(msg.data);
|
||||||
|
ptyProc.write(msg.data);
|
||||||
|
}
|
||||||
if (msg.type === 'resize') ptyProc.resize(Number(msg.cols), Number(msg.rows));
|
if (msg.type === 'resize') ptyProc.resize(Number(msg.cols), Number(msg.rows));
|
||||||
} catch {}
|
} catch {}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => { try { ptyProc.kill(); } catch {} });
|
ws.on('close', () => {
|
||||||
|
logStream.end(`\n=== session end ===\n`);
|
||||||
|
try { ptyProc.kill(); } catch {}
|
||||||
|
});
|
||||||
ptyProc.onExit(() => { try { ws.close(); } catch {} });
|
ptyProc.onExit(() => { try { ws.close(); } catch {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,327 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ja" data-app-id="posimai-dev">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('posimai-dev-theme') || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');
|
||||||
|
document.documentElement.setAttribute('data-theme-pref', t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="description" content="posimai-dev session history">
|
||||||
|
<meta name="color-scheme" content="dark light">
|
||||||
|
<meta name="theme-color" content="#0C1221">
|
||||||
|
<title>セッション履歴 — posimai-dev</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://posimai-ui.vercel.app/v1/base.css">
|
||||||
|
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--accent: #A78BFA;
|
||||||
|
--accent-dim: rgba(167, 139, 250, 0.15);
|
||||||
|
--dev-bg: #0C1221;
|
||||||
|
}
|
||||||
|
[data-theme="light"] { --accent: #7C3AED; }
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100dvh;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 15% 60%, rgba(34,211,238,0.07) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse at 85% 25%, rgba(167,139,250,0.07) 0%, transparent 55%),
|
||||||
|
var(--dev-bg);
|
||||||
|
font-family: Inter, sans-serif;
|
||||||
|
color: #F3F4F6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 48px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(12, 18, 33, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.header-left { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.header-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||||
|
.header-title { font-size: 14px; font-weight: 600; color: #F3F4F6; letter-spacing: -0.01em; }
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 5px 12px; border-radius: 8px; border: none; cursor: pointer;
|
||||||
|
background: var(--accent-dim); color: var(--accent);
|
||||||
|
font-size: 12px; font-weight: 500; font-family: Inter, sans-serif;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.back-btn:hover { background: rgba(167,139,250,0.25); }
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px 16px;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
.session-card {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.session-card:hover { background: rgba(167,139,250,0.08); border-color: rgba(167,139,250,0.2); }
|
||||||
|
.session-card.active { background: rgba(167,139,250,0.1); border-color: rgba(167,139,250,0.3); }
|
||||||
|
|
||||||
|
.session-icon {
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
background: var(--accent-dim);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info { flex: 1; min-width: 0; }
|
||||||
|
.session-id {
|
||||||
|
font-size: 13px; font-weight: 500; font-family: monospace;
|
||||||
|
color: #F3F4F6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.session-meta {
|
||||||
|
font-size: 11px; color: rgba(255,255,255,0.35); margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-size {
|
||||||
|
font-size: 11px; color: rgba(255,255,255,0.3);
|
||||||
|
font-family: monospace; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ログビューアー */
|
||||||
|
.log-viewer {
|
||||||
|
display: none;
|
||||||
|
margin-top: 16px;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid rgba(255,255,255,0.07);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.log-viewer.open { display: block; }
|
||||||
|
|
||||||
|
.log-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
.log-header-title { font-size: 12px; color: rgba(255,255,255,0.5); font-family: monospace; }
|
||||||
|
|
||||||
|
.log-close-btn {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: rgba(255,255,255,0.4); padding: 2px;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.log-close-btn:hover { color: #F3F4F6; }
|
||||||
|
|
||||||
|
.log-body {
|
||||||
|
padding: 14px;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #B0BAD3;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
.empty-state-icon { margin-bottom: 12px; }
|
||||||
|
.empty-state-text { font-size: 14px; }
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: rgba(255,255,255,0.3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn { color: rgba(255,255,255,0.5); }
|
||||||
|
.icon-btn:hover { color: #F3F4F6; background: rgba(255,255,255,0.06); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="header-dot" aria-hidden="true"></div>
|
||||||
|
<span class="header-title">セッション履歴</span>
|
||||||
|
</div>
|
||||||
|
<a href="/" class="back-btn">
|
||||||
|
<i data-lucide="arrow-left" style="width:13px;height:13px;stroke-width:1.75"></i>
|
||||||
|
ターミナルに戻る
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="page-title">過去のセッション</div>
|
||||||
|
<div class="page-subtitle" id="sessionCount">読み込み中...</div>
|
||||||
|
|
||||||
|
<div class="session-list" id="sessionList">
|
||||||
|
<div class="loading">読み込み中...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log-viewer" id="logViewer">
|
||||||
|
<div class="log-header">
|
||||||
|
<span class="log-header-title" id="logTitle"></span>
|
||||||
|
<button class="log-close-btn" id="logCloseBtn" aria-label="閉じる">
|
||||||
|
<i data-lucide="x" style="width:14px;height:14px;stroke-width:1.75"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="log-body" id="logBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const sessionList = document.getElementById('sessionList');
|
||||||
|
const sessionCount = document.getElementById('sessionCount');
|
||||||
|
const logViewer = document.getElementById('logViewer');
|
||||||
|
const logTitle = document.getElementById('logTitle');
|
||||||
|
const logBody = document.getElementById('logBody');
|
||||||
|
|
||||||
|
let activeCard = null;
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString('ja-JP', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANSIエスケープを除去して表示
|
||||||
|
function stripAnsi(str) {
|
||||||
|
return str.replace(/\x1b\[[0-9;]*[mGKHFJ]/g, '').replace(/\r/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLog(id) {
|
||||||
|
logBody.textContent = '読み込み中...';
|
||||||
|
logTitle.textContent = id + '.log';
|
||||||
|
logViewer.classList.add('open');
|
||||||
|
logViewer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
|
||||||
|
const text = await r.text();
|
||||||
|
logBody.textContent = stripAnsi(text);
|
||||||
|
} catch (e) {
|
||||||
|
logBody.textContent = '読み込みに失敗しました。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('logCloseBtn').addEventListener('click', () => {
|
||||||
|
logViewer.classList.remove('open');
|
||||||
|
if (activeCard) { activeCard.classList.remove('active'); activeCard = null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSessions() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/sessions');
|
||||||
|
const sessions = await r.json();
|
||||||
|
|
||||||
|
if (!sessions.length) {
|
||||||
|
sessionList.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">
|
||||||
|
<i data-lucide="inbox" style="width:32px;height:32px;stroke-width:1;color:rgba(255,255,255,0.15)"></i>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state-text">セッションはまだありません</div>
|
||||||
|
</div>`;
|
||||||
|
sessionCount.textContent = 'セッションなし';
|
||||||
|
lucide.createIcons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionCount.textContent = `${sessions.length} セッション`;
|
||||||
|
sessionList.innerHTML = '';
|
||||||
|
|
||||||
|
sessions.forEach((s) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'session-card';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="session-icon">
|
||||||
|
<i data-lucide="terminal" style="width:16px;height:16px;stroke-width:1.75;color:var(--accent)"></i>
|
||||||
|
</div>
|
||||||
|
<div class="session-info">
|
||||||
|
<div class="session-id">${s.id}</div>
|
||||||
|
<div class="session-meta">${formatDate(s.mtime)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="session-size">${formatSize(s.size)}</div>`;
|
||||||
|
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
if (activeCard === card) {
|
||||||
|
logViewer.classList.remove('open');
|
||||||
|
card.classList.remove('active');
|
||||||
|
activeCard = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (activeCard) activeCard.classList.remove('active');
|
||||||
|
activeCard = card;
|
||||||
|
card.classList.add('active');
|
||||||
|
loadLog(s.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
sessionList.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
lucide.createIcons();
|
||||||
|
} catch (e) {
|
||||||
|
sessionList.innerHTML = `<div class="empty-state"><div class="empty-state-text">読み込みに失敗しました</div></div>`;
|
||||||
|
sessionCount.textContent = 'エラー';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSessions();
|
||||||
|
lucide.createIcons();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue