feat(my-deepagent): v0.2 PR #3 — FastAPI + SSE + minimal Web GUI (mydeepagent serve)
Closes the "GUI 미존재" gap from the user's first-session requirements (REPL + workflow + GUI). v0.2 PR #1's Postgres migration made a second concurrent writer safe; v0.2 PR #2a/#2b wired durable resume; this commit ships the HTTP + browser surface that uses them. No auth, no multi-tenant, single uvicorn worker — per DR-3 boundaries. v0.3+ will add auth, multi-worker fanout, LISTEN/NOTIFY SSE upgrade. Backend - `src/my_deepagent/api/`: - `app.py` create_app() factory. lifespan stores db/config/personas/ workflows on app.state. CORS allow_origin_regex http://localhost(:port)?. /static mount + /, /{page}.html for the HTML frontend. - `models.py` — pydantic v2 DTOs (extra="forbid") for every route. Auto OpenAPI/Swagger via FastAPI's response_model. - `deps.py` — get_db / get_config / get_personas / get_workflows. - `runner.py` — start_new_run / start_resume. Pre-allocates run_id via new `WorkflowEngine.run(pre_allocated_run_id=...)` so the route returns the id immediately while the engine runs in asyncio.create_task. - `sse.py` — 0.5 s poll over run_events.seq. Emits ServerSentEvent rows; sends `event: done` and HTTP-200-closes when run hits terminal. - `routes/{runs,personas,workflows,budget}.py`: GET /api/runs (list, ?limit + ?state) GET /api/runs/{id} (detail + phases + artifacts + events) POST /api/runs (start; mock-able via runner.start_new_run) POST /api/runs/{id}/resume POST /api/runs/{id}/abort GET /api/runs/{id}/events (SSE; Last-Event-ID header + ?last_event_id) GET /api/personas GET /api/workflows GET /api/budget CLI - `cli/serve.py` mydeepagent serve [--host 127.0.0.1] [--port 8000]. Loud stderr warning if --host is not loopback (no auth = footgun). uvicorn.run(factory=True, workers=1). - `cli/main.py` serve command registered. Static frontend (vanilla HTML/JS/CSS, no build system) - index.html — runs list + budget summary - new.html — start-run form (workflow select, repo path, requirements, per-role persona override) - run.html — run detail + live SSE event log + Resume/Abort buttons - app.js — fetch + EventSource. XSS policy HARDCODED at file top: textContent only, innerHTML/insertAdjacentHTML/outerHTML forbidden. - style.css — dark theme, single file. Engine - WorkflowEngine.run(... pre_allocated_run_id: UUID|None = None). None → uuid4() (existing behavior). Set → use that UUID. Backward compatible. Tests - tests/integration/test_api_read.py (5): list empty, get 404, personas seed count (12), workflows seed (>=3), budget empty. - tests/integration/test_api_write.py (5): missing template 400, extra field 422, resume 404, abort 404, mock-runner happy path. - tests/integration/test_api_sse.py (1): seed terminal run + 3 events, drain stream, assert types present + stream closes within 3 s. - tests/integration/test_api_static.py (5): index/new/run HTML 200, app.js content-type + XSS-policy substring assertion, style.css content-type. - All fixtures use httpx ASGITransport + app.router.lifespan_context (httpx does NOT auto-trigger FastAPI lifespan) + sqlite tmp_path. Gates - ruff check + ruff format --check + mypy --strict: PASS (120 source files) - pytest non-E2E: 603 PASS (12.15 s) — +16 from new API tests - pytest E2E real OpenRouter on Postgres: PASS 60.44 s (baseline 71–122 s range; well within DR-3 acceptance threshold ≤+20%) Manual browser verification deferred to a follow-up (docker compose up, mydeepagent serve, open http://localhost:8000). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
324
my-deepagent/static/app.js
Normal file
324
my-deepagent/static/app.js
Normal file
@@ -0,0 +1,324 @@
|
||||
/* my-deepagent Web GUI — vanilla JS for v0.2 PR #3.
|
||||
*
|
||||
* SECURITY (XSS policy):
|
||||
* All user-controlled strings MUST be inserted via element.textContent.
|
||||
* element.innerHTML / insertAdjacentHTML / outerHTML are FORBIDDEN.
|
||||
* See plan.md D3 "GUI 정책 명시" — markdown / HTML rendering is v0.3+.
|
||||
*/
|
||||
|
||||
const API = window.location.origin + "/api";
|
||||
|
||||
function $(sel) { return document.querySelector(sel); }
|
||||
function $$(sel) { return Array.from(document.querySelectorAll(sel)); }
|
||||
|
||||
async function jsonFetch(path, opts = {}) {
|
||||
const r = await fetch(API + path, opts);
|
||||
if (!r.ok) {
|
||||
let detail = r.statusText;
|
||||
try {
|
||||
const body = await r.json();
|
||||
detail = body.detail || JSON.stringify(body);
|
||||
} catch (_) { /* ignore */ }
|
||||
throw new Error(`${r.status} ${detail}`);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function setError(msg) {
|
||||
const el = $("#error");
|
||||
if (!el) return;
|
||||
if (msg) {
|
||||
el.textContent = msg;
|
||||
el.style.display = "block";
|
||||
} else {
|
||||
el.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// =============== index.html ===============
|
||||
|
||||
async function renderRunsList() {
|
||||
setError("");
|
||||
let runs;
|
||||
try {
|
||||
runs = await jsonFetch("/runs?limit=50");
|
||||
} catch (e) {
|
||||
setError(`runs 목록을 불러오지 못했습니다: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
const tbody = $("#runs tbody");
|
||||
tbody.replaceChildren();
|
||||
if (runs.length === 0) {
|
||||
const tr = document.createElement("tr");
|
||||
const td = document.createElement("td");
|
||||
td.colSpan = 6;
|
||||
td.className = "empty";
|
||||
td.textContent = "아직 실행된 run이 없습니다. 신규 run을 시작해 보세요.";
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
for (const r of runs) {
|
||||
const tr = document.createElement("tr");
|
||||
const cells = [
|
||||
["id", r.id.slice(0, 8) + "…", { href: `/run.html?id=${r.id}` }],
|
||||
["state", r.state, { state: r.state }],
|
||||
["repo", r.repo_path],
|
||||
["branch", r.base_branch],
|
||||
["created", (r.created_at || "").slice(0, 19)],
|
||||
["ended", (r.ended_at || "—").slice(0, 19)],
|
||||
];
|
||||
for (const [_, v, opts] of cells) {
|
||||
const td = document.createElement("td");
|
||||
if (opts && opts.state) td.className = `state-${opts.state}`;
|
||||
if (opts && opts.href) {
|
||||
const a = document.createElement("a");
|
||||
a.href = opts.href;
|
||||
a.textContent = v;
|
||||
td.appendChild(a);
|
||||
} else {
|
||||
td.textContent = v;
|
||||
}
|
||||
tr.appendChild(td);
|
||||
}
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderBudgetSummary() {
|
||||
const container = $("#budget-summary");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
try {
|
||||
const summary = await jsonFetch("/budget");
|
||||
function line(scope, spent, cap, warn) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "budget-line";
|
||||
const left = document.createElement("span");
|
||||
left.className = "scope";
|
||||
left.textContent = scope;
|
||||
const right = document.createElement("span");
|
||||
right.className = "amount";
|
||||
const capStr = cap != null ? ` / $${cap.toFixed(2)}` : "";
|
||||
right.textContent = `$${spent.toFixed(4)}${capStr}`;
|
||||
if (cap != null && spent >= cap) right.classList.add("over");
|
||||
else if (warn != null && spent >= warn) right.classList.add("warn");
|
||||
div.appendChild(left);
|
||||
div.appendChild(right);
|
||||
container.appendChild(div);
|
||||
}
|
||||
if (summary.day) line(summary.day.scope, summary.day.spent_usd, summary.day.cap_usd, summary.day.warn_usd);
|
||||
for (const r of summary.runs) line(r.scope, r.spent_usd, r.cap_usd, r.warn_usd);
|
||||
for (const p of summary.personas) line(p.scope, p.spent_usd, p.cap_usd, p.warn_usd);
|
||||
if (!summary.day && summary.runs.length === 0 && summary.personas.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty";
|
||||
empty.textContent = "지출 기록이 없습니다.";
|
||||
container.appendChild(empty);
|
||||
}
|
||||
} catch (e) {
|
||||
const err = document.createElement("div");
|
||||
err.className = "empty";
|
||||
err.textContent = `budget 정보를 불러오지 못했습니다: ${e.message}`;
|
||||
container.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
// =============== new.html ===============
|
||||
|
||||
async function renderNewRunForm() {
|
||||
setError("");
|
||||
const tplSelect = $("#template");
|
||||
const overrideContainer = $("#override-fields");
|
||||
let workflows = [];
|
||||
let personas = [];
|
||||
try {
|
||||
[workflows, personas] = await Promise.all([
|
||||
jsonFetch("/workflows"),
|
||||
jsonFetch("/personas"),
|
||||
]);
|
||||
} catch (e) {
|
||||
setError(`workflow / persona 목록을 불러오지 못했습니다: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
for (const w of workflows) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = w.path.replace(/^.*workflows\//, "");
|
||||
opt.textContent = `${w.name}@${w.version} — ${w.description || ""}`;
|
||||
opt.dataset.roles = JSON.stringify(w.roles.map((r) => r.id));
|
||||
tplSelect.appendChild(opt);
|
||||
}
|
||||
tplSelect.addEventListener("change", () => {
|
||||
overrideContainer.replaceChildren();
|
||||
const roles = JSON.parse(tplSelect.selectedOptions[0].dataset.roles || "[]");
|
||||
for (const role of roles) {
|
||||
const label = document.createElement("label");
|
||||
label.textContent = `${role} (선택 사항: persona 이름)`;
|
||||
const input = document.createElement("input");
|
||||
input.dataset.role = role;
|
||||
input.className = "override-input";
|
||||
input.placeholder = "openrouter-deepseek-spec-writer@1";
|
||||
overrideContainer.appendChild(label);
|
||||
overrideContainer.appendChild(input);
|
||||
}
|
||||
});
|
||||
if (tplSelect.options.length > 0) {
|
||||
tplSelect.value = tplSelect.options[0].value;
|
||||
tplSelect.dispatchEvent(new Event("change"));
|
||||
}
|
||||
|
||||
$("#start-form").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
setError("");
|
||||
const override = {};
|
||||
for (const input of $$(".override-input")) {
|
||||
if (input.value.trim()) override[input.dataset.role] = input.value.trim();
|
||||
}
|
||||
const body = {
|
||||
template_path: tplSelect.value,
|
||||
repo_path: $("#repo-path").value.trim(),
|
||||
base_branch: $("#base-branch").value.trim() || "main",
|
||||
requirements_md: $("#requirements").value,
|
||||
};
|
||||
if (Object.keys(override).length > 0) body.override = override;
|
||||
try {
|
||||
const r = await jsonFetch("/runs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
window.location.href = `/run.html?id=${r.run_id}`;
|
||||
} catch (e) {
|
||||
setError(`run 시작 실패: ${e.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =============== run.html ===============
|
||||
|
||||
function getRunIdFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("id");
|
||||
}
|
||||
|
||||
async function renderRunDetail() {
|
||||
setError("");
|
||||
const runId = getRunIdFromUrl();
|
||||
if (!runId) {
|
||||
setError("URL에 ?id=<run_id> 가 필요합니다.");
|
||||
return;
|
||||
}
|
||||
$("#run-id").textContent = runId;
|
||||
try {
|
||||
const detail = await jsonFetch(`/runs/${runId}`);
|
||||
$("#run-state").textContent = detail.run.state;
|
||||
$("#run-state").className = `state-${detail.run.state}`;
|
||||
$("#run-repo").textContent = `${detail.run.repo_path} @ ${detail.run.base_branch}`;
|
||||
$("#run-worktree").textContent = detail.run.worktree_root;
|
||||
$("#run-report").textContent = detail.run.final_report_path || "—";
|
||||
|
||||
const phaseTbody = $("#phases tbody");
|
||||
phaseTbody.replaceChildren();
|
||||
for (const p of detail.phases) {
|
||||
const tr = document.createElement("tr");
|
||||
const cells = [
|
||||
p.phase_key,
|
||||
p.state,
|
||||
String(p.attempts),
|
||||
(p.started_at || "—").slice(0, 19),
|
||||
(p.ended_at || "—").slice(0, 19),
|
||||
];
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = cells[i];
|
||||
if (i === 1) td.className = `state-${p.state}`;
|
||||
tr.appendChild(td);
|
||||
}
|
||||
phaseTbody.appendChild(tr);
|
||||
}
|
||||
|
||||
const isTerminal = ["completed", "failed", "aborted"].includes(detail.run.state);
|
||||
$("#resume-btn").disabled = isTerminal;
|
||||
$("#abort-btn").disabled = isTerminal;
|
||||
} catch (e) {
|
||||
setError(`run 정보를 불러오지 못했습니다: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
startEventStream(runId);
|
||||
}
|
||||
|
||||
function startEventStream(runId) {
|
||||
const eventsContainer = $("#events");
|
||||
eventsContainer.replaceChildren();
|
||||
const src = new EventSource(`${API}/runs/${runId}/events`);
|
||||
src.addEventListener("event", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.data);
|
||||
const line = document.createElement("div");
|
||||
line.className = "event-line";
|
||||
const ts = document.createElement("span");
|
||||
ts.className = "ts";
|
||||
ts.textContent = (data.ts || "").slice(0, 19);
|
||||
const type = document.createElement("span");
|
||||
type.className = "type";
|
||||
type.textContent = ` ${data.type}`;
|
||||
line.appendChild(ts);
|
||||
line.appendChild(type);
|
||||
if (data.payload && Object.keys(data.payload).length > 0) {
|
||||
const payload = document.createElement("span");
|
||||
payload.textContent = " " + JSON.stringify(data.payload);
|
||||
line.appendChild(payload);
|
||||
}
|
||||
eventsContainer.appendChild(line);
|
||||
eventsContainer.scrollTop = eventsContainer.scrollHeight;
|
||||
} catch (_) { /* ignore */ }
|
||||
});
|
||||
src.addEventListener("done", () => {
|
||||
src.close();
|
||||
// Refresh the detail panel one last time to pick up final state.
|
||||
setTimeout(() => renderRunDetail(), 300);
|
||||
});
|
||||
src.onerror = () => {
|
||||
src.close();
|
||||
};
|
||||
}
|
||||
|
||||
async function abortRun() {
|
||||
const runId = getRunIdFromUrl();
|
||||
if (!runId) return;
|
||||
if (!confirm("정말 이 run을 abort 할까요?")) return;
|
||||
try {
|
||||
await jsonFetch(`/runs/${runId}/abort`, { method: "POST" });
|
||||
renderRunDetail();
|
||||
} catch (e) {
|
||||
setError(`abort 실패: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeRun() {
|
||||
const runId = getRunIdFromUrl();
|
||||
if (!runId) return;
|
||||
try {
|
||||
await jsonFetch(`/runs/${runId}/resume`, { method: "POST" });
|
||||
renderRunDetail();
|
||||
} catch (e) {
|
||||
setError(`resume 실패: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// =============== bootstrap ===============
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const page = document.body.dataset.page;
|
||||
if (page === "index") {
|
||||
renderRunsList();
|
||||
renderBudgetSummary();
|
||||
} else if (page === "new") {
|
||||
renderNewRunForm();
|
||||
} else if (page === "run") {
|
||||
renderRunDetail();
|
||||
$("#abort-btn").addEventListener("click", abortRun);
|
||||
$("#resume-btn").addEventListener("click", resumeRun);
|
||||
}
|
||||
});
|
||||
39
my-deepagent/static/index.html
Normal file
39
my-deepagent/static/index.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>my-deepagent · runs</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body data-page="index">
|
||||
<header>
|
||||
<h1>my-deepagent</h1>
|
||||
<nav>
|
||||
<a href="/">runs</a>
|
||||
<a href="/new.html">새 run</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<div id="error" class="error-banner" style="display:none"></div>
|
||||
<h2>최근 run 50개</h2>
|
||||
<table id="runs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>run id</th>
|
||||
<th>state</th>
|
||||
<th>repo</th>
|
||||
<th>branch</th>
|
||||
<th>created</th>
|
||||
<th>ended</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<h2>예산 (현재)</h2>
|
||||
<div id="budget-summary"></div>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
53
my-deepagent/static/new.html
Normal file
53
my-deepagent/static/new.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>my-deepagent · 새 run</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body data-page="new">
|
||||
<header>
|
||||
<h1>my-deepagent · 새 run 시작</h1>
|
||||
<nav>
|
||||
<a href="/">runs</a>
|
||||
<a href="/new.html">새 run</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<div id="error" class="error-banner" style="display:none"></div>
|
||||
|
||||
<form id="start-form">
|
||||
<div class="form-row">
|
||||
<label for="template">워크플로우 템플릿</label>
|
||||
<select id="template" required></select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="repo-path">repo 절대경로</label>
|
||||
<input id="repo-path" type="text" placeholder="/Users/me/projects/my-thing" required />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="base-branch">base branch</label>
|
||||
<input id="base-branch" type="text" value="main" />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="requirements">requirements (markdown 가능)</label>
|
||||
<textarea id="requirements" rows="6" placeholder="이 workflow가 다룰 요구사항을 자유롭게 적어주세요."></textarea>
|
||||
</div>
|
||||
|
||||
<h2>persona override (선택)</h2>
|
||||
<p class="empty" style="padding:0">비워두면 자동 선택. 값은 <code>persona-name@버전</code> 형식.</p>
|
||||
<div id="override-fields"></div>
|
||||
|
||||
<div class="action-bar">
|
||||
<button type="submit">▶︎ 시작</button>
|
||||
<a class="button" href="/">취소</a>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
51
my-deepagent/static/run.html
Normal file
51
my-deepagent/static/run.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>my-deepagent · run 상세</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body data-page="run">
|
||||
<header>
|
||||
<h1>my-deepagent · run 상세</h1>
|
||||
<nav>
|
||||
<a href="/">runs</a>
|
||||
<a href="/new.html">새 run</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<div id="error" class="error-banner" style="display:none"></div>
|
||||
|
||||
<h2>요약</h2>
|
||||
<div class="budget-line"><span class="scope">run id</span><span id="run-id" class="amount"></span></div>
|
||||
<div class="budget-line"><span class="scope">state</span><span id="run-state" class="amount"></span></div>
|
||||
<div class="budget-line"><span class="scope">repo</span><span id="run-repo" class="amount"></span></div>
|
||||
<div class="budget-line"><span class="scope">worktree</span><span id="run-worktree" class="amount"></span></div>
|
||||
<div class="budget-line"><span class="scope">final report</span><span id="run-report" class="amount"></span></div>
|
||||
|
||||
<div class="action-bar">
|
||||
<button id="resume-btn" disabled>▶︎ resume</button>
|
||||
<button id="abort-btn" class="danger" disabled>■ abort</button>
|
||||
</div>
|
||||
|
||||
<h2>phases</h2>
|
||||
<table id="phases">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>key</th>
|
||||
<th>state</th>
|
||||
<th>attempts</th>
|
||||
<th>started</th>
|
||||
<th>ended</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<h2>실시간 이벤트 (SSE)</h2>
|
||||
<div id="events" class="events"></div>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
192
my-deepagent/static/style.css
Normal file
192
my-deepagent/static/style.css
Normal file
@@ -0,0 +1,192 @@
|
||||
/* my-deepagent Web GUI — v0.2 PR #3
|
||||
* Vanilla CSS. No framework. Single dark-friendly theme tuned for
|
||||
* data-heavy tables.
|
||||
*/
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Apple SD Gothic Neo",
|
||||
"Noto Sans KR", "Helvetica Neue", Arial, sans-serif;
|
||||
margin: 0;
|
||||
background: #0f1115;
|
||||
color: #e6e7eb;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #1a1d24;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid #2a2d36;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #f5f6f9;
|
||||
}
|
||||
|
||||
header nav a {
|
||||
color: #8db4ff;
|
||||
margin-left: 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
header nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
color: #f5f6f9;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #161922;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid #232633;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #1f2230;
|
||||
color: #c4c6d0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
td.state-completed { color: #8ee084; }
|
||||
td.state-running, td.state-executing { color: #f5d674; }
|
||||
td.state-failed, td.state-aborted { color: #f08585; }
|
||||
td.state-pending, td.state-created { color: #8db4ff; }
|
||||
|
||||
a {
|
||||
color: #8db4ff;
|
||||
}
|
||||
|
||||
button, .button {
|
||||
background: #2c3145;
|
||||
color: #e6e7eb;
|
||||
border: 1px solid #3a3f55;
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button:hover, .button:hover {
|
||||
background: #353a54;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #4a2a2a;
|
||||
border-color: #5e3535;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #5e3535;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
background: #1a1d24;
|
||||
color: #e6e7eb;
|
||||
border: 1px solid #2a2d36;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
color: #c4c6d0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #6c7080;
|
||||
font-style: italic;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #161922;
|
||||
border: 1px solid #232633;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
font-family: "SF Mono", "Monaco", "Cascadia Code", monospace;
|
||||
}
|
||||
|
||||
.events {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
background: #161922;
|
||||
border: 1px solid #232633;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
font-family: "SF Mono", "Monaco", "Cascadia Code", monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.event-line {
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.event-line .ts { color: #6c7080; }
|
||||
.event-line .type { color: #8db4ff; font-weight: 500; }
|
||||
|
||||
.action-bar {
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.budget-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.budget-line .scope { color: #c4c6d0; }
|
||||
.budget-line .amount { color: #8ee084; }
|
||||
.budget-line .amount.warn { color: #f5d674; }
|
||||
.budget-line .amount.over { color: #f08585; }
|
||||
|
||||
.error-banner {
|
||||
background: #4a2a2a;
|
||||
border: 1px solid #5e3535;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #f4c1c1;
|
||||
}
|
||||
Reference in New Issue
Block a user