|
|
@@ -1,288 +0,0 @@
|
|
|
-#!/usr/bin/env python3
|
|
|
-import os
|
|
|
-import tempfile
|
|
|
-import threading
|
|
|
-from flask import Flask, jsonify, request, abort, render_template_string
|
|
|
-
|
|
|
-app = Flask(__name__)
|
|
|
-
|
|
|
-# --- Configuration ---
|
|
|
-VALUE_FILE = os.environ.get("VALUE_FILE", "value.txt")
|
|
|
-POLL_INTERVAL_MS = int(os.environ.get("POLL_INTERVAL_MS", "20000")) # client refresh rate
|
|
|
-_write_lock = threading.Lock()
|
|
|
-
|
|
|
-
|
|
|
-# --- Helpers ---
|
|
|
-def _clamp_to_range(val: int) -> int:
|
|
|
- return max(0, min(99, int(val)))
|
|
|
-
|
|
|
-
|
|
|
-def _ensure_file_exists():
|
|
|
- """Create the value file with default '0' if missing or invalid."""
|
|
|
- if not os.path.exists(VALUE_FILE):
|
|
|
- _atomic_write("0")
|
|
|
- return
|
|
|
- try:
|
|
|
- _ = _read_value()
|
|
|
- except Exception:
|
|
|
- _atomic_write("0")
|
|
|
-
|
|
|
-
|
|
|
-def _read_value() -> int:
|
|
|
- """Read the current numeric value from disk."""
|
|
|
- with open(VALUE_FILE, "r", encoding="utf-8") as f:
|
|
|
- raw = f.read().strip()
|
|
|
- # Allow blank/new files to recover to 0
|
|
|
- if raw == "":
|
|
|
- return 0
|
|
|
- n = int(raw)
|
|
|
- return _clamp_to_range(n)
|
|
|
-
|
|
|
-
|
|
|
-def _atomic_write(text: str):
|
|
|
- """Atomically write text to VALUE_FILE."""
|
|
|
- dir_name = os.path.dirname(os.path.abspath(VALUE_FILE)) or "."
|
|
|
- os.makedirs(dir_name, exist_ok=True)
|
|
|
- fd, tmp_path = tempfile.mkstemp(prefix=".value-", dir=dir_name, text=True)
|
|
|
- try:
|
|
|
- with os.fdopen(fd, "w", encoding="utf-8") as tmp:
|
|
|
- tmp.write(text)
|
|
|
- tmp.flush()
|
|
|
- os.fsync(tmp.fileno())
|
|
|
- os.replace(tmp_path, VALUE_FILE) # atomic on POSIX/Windows
|
|
|
- finally:
|
|
|
- # If replace succeeded, tmp_path no longer exists; ignore errors.
|
|
|
- try:
|
|
|
- if os.path.exists(tmp_path):
|
|
|
- os.remove(tmp_path)
|
|
|
- except Exception:
|
|
|
- pass
|
|
|
-
|
|
|
-
|
|
|
-def _get_mtime() -> float:
|
|
|
- try:
|
|
|
- return os.path.getmtime(VALUE_FILE)
|
|
|
- except FileNotFoundError:
|
|
|
- return 0.0
|
|
|
-
|
|
|
-
|
|
|
-# --- Routes ---
|
|
|
-@app.route("/", methods=["GET"])
|
|
|
-def index():
|
|
|
- _ensure_file_exists()
|
|
|
- val = _read_value()
|
|
|
- mtime = _get_mtime()
|
|
|
-
|
|
|
- # Single-file template for convenience
|
|
|
- html = """
|
|
|
-<!doctype html>
|
|
|
-<html lang="en">
|
|
|
-<head>
|
|
|
- <meta charset="utf-8" />
|
|
|
- <meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
|
- <title>Tiny Value Server</title>
|
|
|
- <style>
|
|
|
- :root { --accent: #2b7cff; --bg:#0b0d12; --card:#121722; --text:#e8eefc; --muted:#9db0d7; }
|
|
|
- body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color:var(--text); background:linear-gradient(180deg, #0b0d12 0%, #0e1320 100%); }
|
|
|
- .wrap { max-width: 880px; margin: 40px auto; padding: 24px; background: var(--card); border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.35); }
|
|
|
- h1 { margin: 0 0 8px; font-size: 1.5rem; }
|
|
|
- .sub { color: var(--muted); margin-bottom: 24px; }
|
|
|
- .value-display { font-size: clamp(48px, 9vw, 96px); font-weight: 800; text-align:center; letter-spacing: 2px; margin: 14px 0 6px; }
|
|
|
- .mtime { text-align:center; color: var(--muted); font-size: 0.9rem; margin-bottom: 18px; }
|
|
|
- .buttons { display:grid; grid-template-columns: repeat(4, minmax(100px,1fr)); gap:12px; margin: 12px 0 22px;}
|
|
|
- button.big {
|
|
|
- font-size: 24px; font-weight: 700; padding: 18px 8px; border-radius: 12px; border:1px solid #2a3245; background:#172033; color:var(--text);
|
|
|
- cursor:pointer; transition: transform 0.06s ease, background 0.2s ease, box-shadow 0.2s ease;
|
|
|
- box-shadow: inset 0 0 0 1px #2a3245, 0 8px 20px rgba(0,0,0,0.35);
|
|
|
- }
|
|
|
- button.big:hover { transform: translateY(-1px); background:#1a2742; }
|
|
|
- button.primary { background: linear-gradient(180deg, #2b7cff, #2a5bff); border:none; color:white; }
|
|
|
- .control-row { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
|
|
- input[type="number"] {
|
|
|
- width: 100px; font-size: 18px; padding: 10px 12px; border-radius: 10px; border:1px solid #2a3245; background:#0d121c; color:var(--text);
|
|
|
- }
|
|
|
- input[type="range"] { width: min(100%, 520px); }
|
|
|
- .save { padding: 12px 16px; border-radius: 10px; border:0; background: var(--accent); color:white; font-weight:700; cursor:pointer; }
|
|
|
- .status { min-height: 1.4em; color: var(--muted); }
|
|
|
- .footer { margin-top: 24px; color: var(--muted); font-size: 0.9rem; }
|
|
|
- .grid { display:grid; grid-template-columns: 1fr; gap:18px; }
|
|
|
- @media (min-width: 720px) {
|
|
|
- .grid { grid-template-columns: 1.2fr 1fr; align-items: center; }
|
|
|
- }
|
|
|
- </style>
|
|
|
-</head>
|
|
|
-<body>
|
|
|
- <div class="wrap">
|
|
|
- <h1>Tiny Value Server</h1>
|
|
|
- <div class="sub">Stores a single integer 0–99 in <code>{{value_file}}</code>. Updates live if the file changes.</div>
|
|
|
-
|
|
|
- <div class="grid">
|
|
|
- <div>
|
|
|
- <div class="value-display" id="valueDisplay">{{value}}</div>
|
|
|
- <div class="mtime" id="mtime">last updated: {{mtime_human}}</div>
|
|
|
-
|
|
|
- <div class="buttons">
|
|
|
- <button class="big" data-value="0">0</button>
|
|
|
- <button class="big" data-value="7">7</button>
|
|
|
- <button class="big" data-value="33">33</button>
|
|
|
- <button class="big primary" data-value="99">99</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div>
|
|
|
- <label for="valueRange">Set a custom value (0–99)</label>
|
|
|
- <div class="control-row">
|
|
|
- <input id="valueNumber" type="number" inputmode="numeric" min="0" max="99" value="{{value}}">
|
|
|
- <input id="valueRange" type="range" min="0" max="99" value="{{value}}">
|
|
|
- <button id="saveBtn" class="save">Save</button>
|
|
|
- </div>
|
|
|
- <div id="status" class="status"></div>
|
|
|
- <div class="footer">Auto-refresh every {{poll_ms}} ms.</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
-<script>
|
|
|
-const POLL_MS = {{poll_ms}};
|
|
|
-const valueDisplay = document.getElementById("valueDisplay");
|
|
|
-const mtimeEl = document.getElementById("mtime");
|
|
|
-const statusEl = document.getElementById("status");
|
|
|
-const num = document.getElementById("valueNumber");
|
|
|
-const rng = document.getElementById("valueRange");
|
|
|
-const saveBtn = document.getElementById("saveBtn");
|
|
|
-
|
|
|
-function clamp(n) {
|
|
|
- n = Number(n);
|
|
|
- if (!Number.isFinite(n)) return 0;
|
|
|
- return Math.max(0, Math.min(99, Math.trunc(n)));
|
|
|
-}
|
|
|
-
|
|
|
-function setStatus(msg, ok=true) {
|
|
|
- statusEl.textContent = msg || "";
|
|
|
- statusEl.style.color = ok ? "#9db0d7" : "#ff9aa2";
|
|
|
-}
|
|
|
-
|
|
|
-function syncInputs(v) {
|
|
|
- num.value = v;
|
|
|
- rng.value = v;
|
|
|
-}
|
|
|
-
|
|
|
-async function fetchValue() {
|
|
|
- try {
|
|
|
- const r = await fetch("/value", { cache: "no-store" });
|
|
|
- if (!r.ok) throw new Error("HTTP " + r.status);
|
|
|
- const data = await r.json();
|
|
|
- valueDisplay.textContent = data.value;
|
|
|
- mtimeEl.textContent = "last updated: " + data.mtime_human;
|
|
|
- // Only pull slider if server changed
|
|
|
- if (Number(num.value) !== data.value) {
|
|
|
- syncInputs(data.value);
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- setStatus("Failed to refresh: " + e.message, false);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-async function postValue(v) {
|
|
|
- try {
|
|
|
- const r = await fetch("/set", {
|
|
|
- method: "POST",
|
|
|
- headers: { "Content-Type": "application/json" },
|
|
|
- body: JSON.stringify({ value: v })
|
|
|
- });
|
|
|
- if (!r.ok) {
|
|
|
- const t = await r.text();
|
|
|
- throw new Error("HTTP " + r.status + " " + t);
|
|
|
- }
|
|
|
- const data = await r.json();
|
|
|
- setStatus("Saved.");
|
|
|
- valueDisplay.textContent = data.value;
|
|
|
- mtimeEl.textContent = "last updated: " + data.mtime_human;
|
|
|
- syncInputs(data.value);
|
|
|
- } catch (e) {
|
|
|
- setStatus("Save failed: " + e.message, false);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-document.querySelectorAll("button.big").forEach(btn => {
|
|
|
- btn.addEventListener("click", () => {
|
|
|
- const v = clamp(btn.dataset.value);
|
|
|
- syncInputs(v);
|
|
|
- postValue(v);
|
|
|
- });
|
|
|
-});
|
|
|
-
|
|
|
-num.addEventListener("input", () => {
|
|
|
- syncInputs(clamp(num.value));
|
|
|
-});
|
|
|
-rng.addEventListener("input", () => {
|
|
|
- syncInputs(clamp(rng.value));
|
|
|
-});
|
|
|
-
|
|
|
-saveBtn.addEventListener("click", () => {
|
|
|
- const v = clamp(num.value);
|
|
|
- syncInputs(v);
|
|
|
- postValue(v);
|
|
|
-});
|
|
|
-
|
|
|
-// initial and polling
|
|
|
-fetchValue();
|
|
|
-setInterval(fetchValue, POLL_MS);
|
|
|
-</script>
|
|
|
-</body>
|
|
|
-</html>
|
|
|
- """
|
|
|
- return render_template_string(
|
|
|
- html,
|
|
|
- value_file=os.path.abspath(VALUE_FILE),
|
|
|
- value=val,
|
|
|
- mtime_human=_fmt_time(mtime),
|
|
|
- poll_ms=POLL_INTERVAL_MS,
|
|
|
- )
|
|
|
-
|
|
|
-
|
|
|
-@app.route("/value", methods=["GET"])
|
|
|
-def get_value():
|
|
|
- _ensure_file_exists()
|
|
|
- try:
|
|
|
- val = _read_value()
|
|
|
- except Exception:
|
|
|
- # If the file is corrupt or unreadable, reset to 0
|
|
|
- with _write_lock:
|
|
|
- _atomic_write("0")
|
|
|
- val = 0
|
|
|
- mtime = _get_mtime()
|
|
|
- return jsonify({"value": val, "mtime": mtime, "mtime_human": _fmt_time(mtime)})
|
|
|
-
|
|
|
-
|
|
|
-@app.route("/set", methods=["POST"])
|
|
|
-def set_value():
|
|
|
- if not request.is_json:
|
|
|
- abort(400, "Expected application/json body")
|
|
|
- body = request.get_json(silent=True) or {}
|
|
|
- if "value" not in body:
|
|
|
- abort(400, "Missing 'value'")
|
|
|
- try:
|
|
|
- new_val = _clamp_to_range(int(body["value"]))
|
|
|
- except Exception:
|
|
|
- abort(400, "Value must be an integer 0–99")
|
|
|
-
|
|
|
- with _write_lock:
|
|
|
- _atomic_write(str(new_val))
|
|
|
-
|
|
|
- mtime = _get_mtime()
|
|
|
- return jsonify({"ok": True, "value": new_val, "mtime": mtime, "mtime_human": _fmt_time(mtime)})
|
|
|
-
|
|
|
-
|
|
|
-# --- Utilities ---
|
|
|
-import time
|
|
|
-def _fmt_time(mtime: float) -> str:
|
|
|
- if not mtime:
|
|
|
- return "never"
|
|
|
- return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mtime))
|
|
|
-
|
|
|
-
|
|
|
-# --- Entry point ---
|
|
|
-if __name__ == "__main__":
|
|
|
- _ensure_file_exists()
|
|
|
- app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8087")), debug=True)
|