push
This commit is contained in:
@@ -3,15 +3,332 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Demo Web</title>
|
||||
<title>Compose — заметки</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
|
||||
code { background: #f4f4f4; padding: 0.15rem 0.35rem; border-radius: 4px; }
|
||||
: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>
|
||||
<h1>Сервис <code>web</code></h1>
|
||||
<p>Эта страница попала в контейнер при <strong>сборке образа</strong> (<code>COPY</code> в Dockerfile).</p>
|
||||
<p>API стека: <a href="/api/health">/api/health</a>, список заметок: <a href="/api/notes">/api/notes</a>.</p>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user