| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- #!/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)
|