335 lines
11 KiB
HTML
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>
|