Files
docker/examples/multi-service/web/html/index.html
T
2026-05-04 12:16:42 +03:00

335 lines
11 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Compose — заметки</title>
<style>
:root {
--bg: #121214;
--surface: #1c1c21;
--border: #2a2a32;
--text: #ececf1;
--muted: #8b8b98;
--ok: #34c759;
--warn: #d4a017;
--err: #ff5c5c;
--accent: #5b8def;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 15px;
line-height: 1.5;
}
.wrap { max-width: 42rem; margin: 0 auto; padding: 1.25rem 1rem 2.5rem; }
h1 {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 0.25rem;
color: var(--muted);
}
.tagline { font-size: 0.85rem; color: var(--muted); margin-bottom: 1.25rem; }
.status {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
margin-bottom: 1.5rem;
}
@media (min-width: 520px) {
.status { grid-template-columns: repeat(4, 1fr); }
}
.pill {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.65rem 0.75rem;
text-align: center;
}
.pill .label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 0.2rem;
}
.pill .state { font-size: 0.85rem; font-weight: 600; }
.pill .hint { font-size: 0.65rem; color: var(--muted); margin-top: 0.25rem; line-height: 1.3; }
.pill.ok .state { color: var(--ok); }
.pill.bad .state { color: var(--err); }
.pill.wait .state { color: var(--warn); }
section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
section h2 {
margin: 0 0 0.75rem;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
textarea {
width: 100%;
min-height: 4.5rem;
resize: vertical;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 0.65rem 0.75rem;
font: inherit;
margin-bottom: 0.5rem;
}
textarea:focus { outline: none; border-color: var(--accent); }
button, .btnfile label {
display: inline-block;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.5rem 1rem;
font: inherit;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
button:hover, .btnfile label:hover { filter: brightness(1.08); }
button:disabled { opacity: 0.45; cursor: not-allowed; }
.btnfile input { display: none; }
ul.notes { list-style: none; margin: 0; padding: 0; }
ul.notes li {
border-bottom: 1px solid var(--border);
padding: 0.65rem 0;
font-size: 0.9rem;
}
ul.notes li:last-child { border-bottom: none; }
ul.notes .meta { font-size: 0.7rem; color: var(--muted); margin-top: 0.2rem; }
.empty { color: var(--muted); font-size: 0.9rem; padding: 0.5rem 0; }
.uploads { font-size: 0.85rem; color: var(--muted); }
.uploads code { color: var(--text); font-size: 0.8rem; }
.toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.75rem; }
.toolbar button.secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
}
.errbox {
display: none;
background: rgba(255, 92, 92, 0.1);
border: 1px solid rgba(255, 92, 92, 0.35);
color: #ffb4b4;
border-radius: 8px;
padding: 0.5rem 0.65rem;
font-size: 0.8rem;
margin-bottom: 0.75rem;
}
.errbox.show { display: block; }
</style>
</head>
<body>
<div class="wrap">
<h1>Мини-дашборд</h1>
<p class="tagline">Статика из контейнера <code style="color:var(--accent)">web</code>, данные через <code style="color:var(--accent)">proxy</code><code style="color:var(--accent)">api</code> → PostgreSQL.</p>
<div class="status" id="status">
<div class="pill wait" id="pill-proxy">
<div class="label">Прокси</div>
<div class="state"></div>
<div class="hint">nginx → API</div>
</div>
<div class="pill wait" id="pill-web">
<div class="label">Web</div>
<div class="state"></div>
<div class="hint">статика</div>
</div>
<div class="pill wait" id="pill-api">
<div class="label">API</div>
<div class="state"></div>
<div class="hint">Flask</div>
</div>
<div class="pill wait" id="pill-db">
<div class="label">База</div>
<div class="state"></div>
<div class="hint" id="db-hint">Postgres</div>
</div>
</div>
<div class="errbox" id="global-err"></div>
<section>
<div class="toolbar">
<h2 style="margin:0; flex:1;">Заметки</h2>
<button type="button" class="secondary" id="btn-refresh">Обновить</button>
</div>
<textarea id="note-body" placeholder="Текст заметки…"></textarea>
<button type="button" id="btn-add">Добавить</button>
<ul class="notes" id="notes-list"></ul>
</section>
<section>
<h2>Файлы в томе API</h2>
<p class="uploads" style="margin-top:0">Маленький файл уйдёт в именованный том <code>api_uploads</code>.</p>
<div class="toolbar" style="margin-top:0.5rem">
<span class="btnfile"><label for="file-up">Выбрать файл</label><input type="file" id="file-up" /></span>
<button type="button" class="secondary" id="btn-uploads">Обновить список</button>
</div>
<div class="uploads" id="uploads-list"></div>
</section>
</div>
<script>
const $ = (id) => document.getElementById(id);
function setPill(id, ok, text, hint) {
const el = $(id);
if (!el) return;
el.classList.remove("ok", "bad", "wait");
el.classList.add(ok === true ? "ok" : ok === false ? "bad" : "wait");
el.querySelector(".state").textContent = text;
if (hint != null && el.querySelector(".hint")) el.querySelector(".hint").textContent = hint;
}
async function refreshStatus() {
$("global-err").classList.remove("show");
setPill("pill-web", true, "ок", "страница открыта");
setPill("pill-proxy", null, "…", "проверка…");
setPill("pill-api", null, "…", "проверка…");
setPill("pill-db", null, "…", "проверка…");
let data;
try {
const r = await fetch("/api/status", { headers: { Accept: "application/json" } });
const txt = await r.text();
try {
data = JSON.parse(txt);
} catch {
throw new Error("Не JSON от /api/status");
}
if (!r.ok) throw new Error(data.detail || data.error || r.statusText);
} catch (e) {
setPill("pill-proxy", false, "ошибка", "нет ответа");
setPill("pill-api", false, "нет", "fetch");
setPill("pill-db", false, "?", "—");
$("global-err").textContent = String(e.message || e);
$("global-err").classList.add("show");
return;
}
setPill("pill-proxy", true, "ок", "маршрут /api/");
setPill("pill-api", data.api && data.api.ok, data.api && data.api.ok ? "ок" : "нет", "gunicorn");
const db = data.database || {};
setPill(
"pill-db",
db.ok,
db.ok ? "ок" : "нет",
db.ok && db.detail ? String(db.detail).split(" ")[0] + "…" : (db.error || "нет связи").slice(0, 36)
);
}
async function loadNotes() {
const list = $("notes-list");
list.innerHTML = "";
try {
const r = await fetch("/api/notes");
if (!r.ok) throw new Error(await r.text());
const items = await r.json();
if (!items.length) {
list.innerHTML = '<li class="empty">Пока пусто — добавьте заметку выше.</li>';
return;
}
for (const n of items) {
const li = document.createElement("li");
li.textContent = n.body;
const meta = document.createElement("div");
meta.className = "meta";
meta.textContent = "#" + n.id + " · " + (n.created_at || "");
li.appendChild(meta);
list.appendChild(li);
}
} catch (e) {
list.innerHTML = '<li class="empty">Не удалось загрузить заметки: ' + (e.message || e) + "</li>";
}
}
async function addNote() {
const body = $("note-body").value.trim();
if (!body) return;
$("btn-add").disabled = true;
try {
const r = await fetch("/api/notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body }),
});
if (!r.ok) throw new Error(await r.text());
$("note-body").value = "";
await loadNotes();
await refreshStatus();
} catch (e) {
alert(e.message || e);
} finally {
$("btn-add").disabled = false;
}
}
async function loadUploads() {
const el = $("uploads-list");
el.textContent = "Загрузка…";
try {
const r = await fetch("/api/uploads");
if (!r.ok) throw new Error(await r.text());
const j = await r.json();
const files = j.files || [];
el.innerHTML = files.length
? files.map((f) => "<div><code>" + f + "</code></div>").join("")
: "<span>Файлов пока нет.</span>";
} catch (e) {
el.textContent = "Ошибка: " + (e.message || e);
}
}
$("file-up").addEventListener("change", async () => {
const inp = $("file-up");
if (!inp.files || !inp.files[0]) return;
const fd = new FormData();
fd.append("file", inp.files[0]);
try {
const r = await fetch("/api/upload", { method: "POST", body: fd });
if (!r.ok) throw new Error(await r.text());
inp.value = "";
await loadUploads();
} catch (e) {
alert(e.message || e);
}
});
$("btn-add").addEventListener("click", addNote);
$("btn-refresh").addEventListener("click", async () => {
await refreshStatus();
await loadNotes();
});
$("btn-uploads").addEventListener("click", loadUploads);
refreshStatus();
loadNotes();
loadUploads();
setInterval(refreshStatus, 15000);
</script>
</body>
</html>