small-software 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. small_cli/__init__.py +1 -0
  2. small_cli/__main__.py +3 -0
  3. small_cli/access.py +114 -0
  4. small_cli/archive.py +127 -0
  5. small_cli/cli.py +76 -0
  6. small_cli/commands/__init__.py +8 -0
  7. small_cli/commands/access.py +167 -0
  8. small_cli/commands/dashboard.py +49 -0
  9. small_cli/commands/db.py +154 -0
  10. small_cli/commands/deploy.py +248 -0
  11. small_cli/commands/destroy.py +63 -0
  12. small_cli/commands/domain.py +123 -0
  13. small_cli/commands/env.py +151 -0
  14. small_cli/commands/init.py +86 -0
  15. small_cli/commands/link.py +53 -0
  16. small_cli/commands/logs.py +59 -0
  17. small_cli/commands/mail.py +203 -0
  18. small_cli/commands/notify.py +147 -0
  19. small_cli/commands/sleep.py +61 -0
  20. small_cli/common.py +92 -0
  21. small_cli/config.py +82 -0
  22. small_cli/dashboard.py +648 -0
  23. small_cli/i18n.py +55 -0
  24. small_cli/railway.py +369 -0
  25. small_cli/scaffold.py +81 -0
  26. small_cli/templates/fastapi/magic_link.py +675 -0
  27. small_cli/templates/fastapi/main.py +67 -0
  28. small_cli/templates/fastapi/requirements.txt +2 -0
  29. small_cli/templates/node-http/magic-link.js +509 -0
  30. small_cli/templates/node-http/package.json +11 -0
  31. small_cli/templates/node-http/server.js +33 -0
  32. small_cli/templates/python-http/app.py +47 -0
  33. small_cli/templates/python-http/magic_link.py +675 -0
  34. small_cli/templates/streamlit/app.py +181 -0
  35. small_cli/templates/streamlit/requirements.txt +1 -0
  36. small_software-0.6.0.dist-info/METADATA +559 -0
  37. small_software-0.6.0.dist-info/RECORD +41 -0
  38. small_software-0.6.0.dist-info/WHEEL +5 -0
  39. small_software-0.6.0.dist-info/entry_points.txt +2 -0
  40. small_software-0.6.0.dist-info/licenses/LICENSE +21 -0
  41. small_software-0.6.0.dist-info/top_level.txt +1 -0
small_cli/dashboard.py ADDED
@@ -0,0 +1,648 @@
1
+ """`small dashboard`: локальная страница со списком микро-приложений в папке.
2
+
3
+ Приложением считается папка, где есть small.json и/или .small.json. Привязку и ссылки берём из локальных
4
+ файлов. Если задан RAILWAY_API_TOKEN, для привязанных приложений дополнительно спрашиваем у Railway статус
5
+ последнего деплоя; любая ошибка (сеть, таймаут, токен) просто оставляет локальные данные.
6
+ Значения переменных из small.json и сам токен Railway никогда не отдаются в браузер.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from datetime import datetime, timedelta, timezone
15
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
16
+ from pathlib import Path
17
+ from urllib.parse import parse_qs, urlparse
18
+
19
+ from . import access
20
+ from .archive import ALWAYS_IGNORED
21
+ from .config import CONFIG_FILE, STATE_FILE, ConfigError, load_config, load_state
22
+ from .i18n import lang, tr
23
+ from .railway import Railway, RailwayError
24
+
25
+ DEFAULT_PORT = 3000
26
+ MAX_DEPTH = 6 # глубже не ищем: `small dashboard` из корня диска не должен обходить весь диск
27
+ DOMAIN_RE = re.compile(r"[A-Za-z0-9.-]+\Z")
28
+ RAILWAY_ID_RE = re.compile(r"[A-Za-z0-9-]+\Z")
29
+ DEPLOY_STATUS_RE = re.compile(r"[A-Z_]{1,32}\Z")
30
+ LIVE_TIMEOUT = 5 # секунд на запрос к Railway: кнопка «Обновить» не должна зависать
31
+ LIVE_WORKERS = 8
32
+ LOG_LINES = 200
33
+ LOAD_DAYS = 7
34
+ # Тарифы Railway за месяц (docs.railway.com/reference/pricing/plans) на единицу средней нагрузки из metrics.
35
+ MONTHLY_PRICES_USD = {
36
+ "CPU_USAGE": 20.0, # $20 за vCPU, загруженный весь месяц
37
+ "MEMORY_USAGE_GB": 10.0, # $10 за ГБ памяти весь месяц
38
+ "DISK_USAGE_GB": 0.15, # $0.15 за ГБ тома весь месяц
39
+ }
40
+
41
+
42
+ def scan_apps(root, max_depth=MAX_DEPTH):
43
+ """Находит приложения под root. Внутрь найденного приложения не спускается."""
44
+ root = Path(root).resolve()
45
+ apps = []
46
+ for dirpath, dirnames, filenames in os.walk(root):
47
+ folder = Path(dirpath)
48
+ if CONFIG_FILE in filenames or STATE_FILE in filenames:
49
+ apps.append(describe_app(folder, root))
50
+ dirnames[:] = []
51
+ continue
52
+ depth = len(folder.relative_to(root).parts)
53
+ dirnames[:] = sorted(d for d in dirnames if d not in ALWAYS_IGNORED) if depth < max_depth else []
54
+ return sorted(apps, key=lambda a: a["relPath"].lower())
55
+
56
+
57
+ def describe_app(folder, root):
58
+ rel = folder.relative_to(root).as_posix()
59
+ app = {"name": folder.name, "path": str(folder), "relPath": "." if rel == "." else rel,
60
+ "status": "not_deployed", "url": None, "railwayUrl": None, "envNames": [], "errors": [],
61
+ "serviceId": None, "environmentId": None, "projectId": None, "deployment": None, "sleep": None,
62
+ "costUsd": None}
63
+ try:
64
+ config = load_config(folder / CONFIG_FILE)
65
+ except ConfigError as e:
66
+ config = {"name": None, "env": {}}
67
+ app["errors"].append(str(e))
68
+ try:
69
+ state = load_state(folder / STATE_FILE)
70
+ except ConfigError as e:
71
+ state = {}
72
+ app["errors"].append(str(e).replace(str(folder / STATE_FILE), STATE_FILE))
73
+
74
+ app["name"] = config["name"] or (state.get("name") if isinstance(state.get("name"), str) else None) or folder.name
75
+ app["envNames"] = sorted(config["env"]) # только имена: значения — секреты
76
+
77
+ if state.get("serviceId"):
78
+ app["status"] = "linked"
79
+ project_id, service_id = state.get("projectId"), state.get("serviceId")
80
+ if all(_is_id(v) for v in (project_id, service_id)):
81
+ app["railwayUrl"] = "https://railway.com/project/%s/service/%s" % (project_id, service_id)
82
+ if _is_id(service_id) and _is_id(state.get("environmentId")):
83
+ app["serviceId"], app["environmentId"] = service_id, state["environmentId"]
84
+ if _is_id(project_id):
85
+ app["projectId"] = project_id
86
+ domain = state.get("domain")
87
+ if domain:
88
+ if isinstance(domain, str) and DOMAIN_RE.match(domain):
89
+ app["url"] = "https://" + domain
90
+ else:
91
+ app["errors"].append(tr("%s: invalid domain %r", "%s: некорректный домен %r") % (STATE_FILE, domain))
92
+ return app
93
+
94
+
95
+ def _is_id(value):
96
+ return isinstance(value, str) and bool(RAILWAY_ID_RE.match(value))
97
+
98
+
99
+ def add_live_status(apps, token, timeout=LIVE_TIMEOUT):
100
+ """Дописывает app["deployment"] = {"status", "createdAt"} из Railway для привязанных приложений.
101
+
102
+ Возвращает режим для страницы: "off" (нет токена), "on" (Railway ответил) или "error" (не ответил ни разу).
103
+ Никогда не бросает исключений: живой статус — украшение, локальные данные важнее.
104
+ """
105
+ linked = [app for app in apps if app["serviceId"] and app["environmentId"]]
106
+ if not token:
107
+ return "off"
108
+ if not linked:
109
+ return "on"
110
+ api = Railway(token)
111
+
112
+ def fetch(app):
113
+ try:
114
+ return app, api.instance_status(app["serviceId"], app["environmentId"], timeout=timeout), True
115
+ except Exception: # сеть, таймаут, неверный токен, удалённый сервис, неожиданный ответ
116
+ return app, None, False
117
+
118
+ answered = False
119
+ with ThreadPoolExecutor(max_workers=min(LIVE_WORKERS, len(linked))) as pool:
120
+ for app, instance, ok in pool.map(fetch, linked):
121
+ answered = answered or ok
122
+ if not ok or not isinstance(instance, dict):
123
+ continue
124
+ app["sleep"] = bool(instance.get("sleep"))
125
+ deployment = instance.get("deployment")
126
+ if isinstance(deployment, dict):
127
+ status = deployment.get("status")
128
+ if isinstance(status, str) and DEPLOY_STATUS_RE.match(status):
129
+ created = deployment.get("createdAt")
130
+ app["deployment"] = {"status": status, "createdAt": created if isinstance(created, str) else None}
131
+ return "on" if answered else "error"
132
+
133
+
134
+ def estimate_cost(load):
135
+ """$ в месяц при средней нагрузке load (ядра, ГБ), по тарифам Railway; без исходящего трафика
136
+ и бесплатного лимита тарифа ($5 Hobby / $20 Pro)."""
137
+ return sum(MONTHLY_PRICES_USD[m] * float(v) for m, v in load.items() if m in MONTHLY_PRICES_USD)
138
+
139
+
140
+ def add_usage(apps, token, timeout=LIVE_TIMEOUT, days=LOAD_DAYS):
141
+ """Дописывает app["costUsd"] — оценку $/мес для проекта приложения (с его базами) по средней нагрузке
142
+ за последние days дней. Ошибки молча оставляют None: это оценка, а не биллинг."""
143
+ projects = sorted({app["projectId"] for app in apps if app["projectId"]})
144
+ if not token or not projects:
145
+ return
146
+ api = Railway(token)
147
+ end = datetime.now(timezone.utc).replace(microsecond=0)
148
+ start = end - timedelta(days=days)
149
+
150
+ def fetch(project):
151
+ try:
152
+ load = api.average_load(project, start.isoformat().replace("+00:00", "Z"),
153
+ end.isoformat().replace("+00:00", "Z"), timeout=timeout)
154
+ return project, round(estimate_cost(load), 2)
155
+ except Exception:
156
+ return project, None
157
+
158
+ with ThreadPoolExecutor(max_workers=min(LIVE_WORKERS, len(projects))) as pool:
159
+ costs = dict(pool.map(fetch, projects))
160
+ for app in apps:
161
+ if app["projectId"]:
162
+ app["costUsd"] = costs.get(app["projectId"])
163
+
164
+
165
+ class DashboardServer(ThreadingHTTPServer):
166
+ daemon_threads = True
167
+ # В Windows SO_REUSEADDR разрешает занять порт, который уже слушает другой процесс.
168
+ allow_reuse_address = sys.platform != "win32"
169
+
170
+ def __init__(self, address, root, token=None):
171
+ super().__init__(address, DashboardHandler)
172
+ self.root = Path(root).resolve()
173
+ self.token = token # токен Railway для живых статусов; в браузер не уходит
174
+ port = self.server_address[1]
175
+ # Защита от DNS rebinding: чужой сайт, указавший свой домен на 127.0.0.1, получит 403.
176
+ self.allowed_hosts = {"127.0.0.1:%d" % port, "localhost:%d" % port}
177
+
178
+ @property
179
+ def url(self):
180
+ return "http://127.0.0.1:%d/" % self.server_address[1]
181
+
182
+
183
+ def start_server(root, port=DEFAULT_PORT, token=None):
184
+ """Сервер на 127.0.0.1:port; если порт занят — на свободном случайном."""
185
+ try:
186
+ return DashboardServer(("127.0.0.1", port), root, token)
187
+ except OSError:
188
+ if port == 0:
189
+ raise
190
+ return DashboardServer(("127.0.0.1", 0), root, token)
191
+
192
+
193
+ class DashboardHandler(BaseHTTPRequestHandler):
194
+ server_version = "small-dashboard"
195
+
196
+ def do_GET(self):
197
+ if self.headers.get("Host") not in self.server.allowed_hosts:
198
+ return self._send(403, "text/plain; charset=utf-8", b"Forbidden")
199
+ url = urlparse(self.path)
200
+ path, params = url.path, {k: v[-1] for k, v in parse_qs(url.query).items()}
201
+ if path == "/":
202
+ return self._send(200, "text/html; charset=utf-8", render_page().encode("utf-8"))
203
+ if path == "/open":
204
+ return self._open(params.get("app"))
205
+ if path == "/api/logs":
206
+ return self._logs(params.get("app"), params.get("build") == "1")
207
+ if path == "/api/apps":
208
+ apps = scan_apps(self.server.root)
209
+ live = add_live_status(apps, self.server.token)
210
+ if live == "on":
211
+ add_usage(apps, self.server.token)
212
+ payload = {"root": str(self.server.root),
213
+ "scannedAt": datetime.now().astimezone().isoformat(timespec="seconds"),
214
+ "live": live, "apps": apps}
215
+ return self._send(200, "application/json; charset=utf-8",
216
+ json.dumps(payload, ensure_ascii=False).encode("utf-8"))
217
+ self._send(404, "text/plain; charset=utf-8", b"Not found")
218
+
219
+ def _find(self, rel_path):
220
+ """Приложение из списка по relPath — произвольные пути с диска не принимаются."""
221
+ return next((app for app in scan_apps(self.server.root) if app["relPath"] == rel_path), None)
222
+
223
+ def _open(self, rel_path):
224
+ """Редирект на приложение по ссылке-приглашению. Токен уходит только в адресную строку браузера."""
225
+ app = self._find(rel_path)
226
+ if not app or not app["url"]:
227
+ return self._send(404, "text/plain; charset=utf-8",
228
+ tr("App not found or not deployed", "Приложение не найдено или не задеплоено").encode("utf-8"))
229
+ folder = Path(app["path"])
230
+ try:
231
+ entries, _ = access.current(folder, load_state(folder / STATE_FILE))
232
+ except ConfigError:
233
+ entries = []
234
+ self.send_response(302)
235
+ self.send_header("Location", access.invite_link(urlparse(app["url"]).netloc, access.pick(entries)))
236
+ self.send_header("Cache-Control", "no-store")
237
+ self.send_header("Referrer-Policy", "no-referrer")
238
+ self.send_header("Content-Length", "0")
239
+ self.end_headers()
240
+
241
+ def _logs(self, rel_path, build):
242
+ app = self._find(rel_path)
243
+ if not app or not app["serviceId"]:
244
+ return self._json(404, {"error": tr("app not found or not deployed", "приложение не найдено или не задеплоено")})
245
+ if not self.server.token:
246
+ return self._json(400, {"error": tr("RAILWAY_API_TOKEN is required: restart small dashboard with it set",
247
+ "нужен RAILWAY_API_TOKEN: перезапустите small dashboard с ним")})
248
+ try:
249
+ api = Railway(self.server.token)
250
+ deployment = api.latest_deployment(app["serviceId"], app["environmentId"], timeout=LIVE_TIMEOUT)
251
+ if not deployment:
252
+ return self._json(200, {"deployment": None, "lines": []})
253
+ entries = api.log_entries(deployment["id"], build=build, limit=LOG_LINES)
254
+ except (RailwayError, KeyError, TypeError) as e:
255
+ return self._json(502, {"error": "Railway: %s" % e})
256
+ return self._json(200, {
257
+ "deployment": {"id": deployment["id"], "status": deployment["status"]},
258
+ "lines": [{"time": e["timestamp"], "message": e["message"]} for e in entries],
259
+ })
260
+
261
+ def _json(self, code, payload):
262
+ self._send(code, "application/json; charset=utf-8", json.dumps(payload, ensure_ascii=False).encode("utf-8"))
263
+
264
+ def _send(self, code, content_type, body):
265
+ self.send_response(code)
266
+ self.send_header("Content-Type", content_type)
267
+ self.send_header("Content-Length", str(len(body)))
268
+ self.send_header("Cache-Control", "no-store")
269
+ self.end_headers()
270
+ self.wfile.write(body)
271
+
272
+ def log_message(self, format, *args):
273
+ pass
274
+
275
+
276
+ def _page_texts():
277
+ """Тексты, которые собирает JS страницы, на текущем языке: уходят в неё объектом T.
278
+
279
+ {name} — подстановки для fmt() в JS; apps — формы для plural(): три по-русски, две по-английски.
280
+ """
281
+ return {
282
+ "locale": tr("en-US", "ru-RU"), # для toLocaleString / toLocaleTimeString
283
+ "linked": tr("Linked to Railway", "Привязан к Railway"),
284
+ "notDeployed": tr("Not deployed", "Не задеплоен"),
285
+ "deploy": { # подсказка к статусу последнего деплоя; ключи — статусы Railway API
286
+ "SUCCESS": tr("running", "работает"),
287
+ "SLEEPING": tr("sleeping", "спит"),
288
+ "QUEUED": tr("queued", "в очереди"),
289
+ "WAITING": tr("waiting", "ожидает"),
290
+ "INITIALIZING": tr("starting", "запускается"),
291
+ "BUILDING": tr("building", "сборка"),
292
+ "DEPLOYING": tr("deploying", "выкатывается"),
293
+ "FAILED": tr("build failed", "сборка упала"),
294
+ "CRASHED": tr("app crashed", "приложение упало"),
295
+ "REMOVED": tr("removed", "удалён"),
296
+ "REMOVING": tr("removing", "удаляется"),
297
+ "SKIPPED": tr("skipped", "пропущен"),
298
+ "NEEDS_APPROVAL": tr("awaiting approval", "ждёт подтверждения"),
299
+ },
300
+ "lastDeploy": tr("Latest deploy on Railway", "Последний деплой в Railway"),
301
+ "sleepBadge": tr("auto-sleep", "засыпает"),
302
+ "sleepHint": tr("Sleep mode: with no requests the app goes to sleep and uses no resources (small sleep)",
303
+ "Режим сна: без запросов приложение засыпает и не тратит ресурсы (small sleep)"),
304
+ "notDeployedYet": tr("Not deployed yet: small deploy", "Ещё не деплоилось: small deploy"),
305
+ "open": tr("Open via invite link", "Открыть по ссылке"),
306
+ "openHint": tr("Opens right away with the access token, skipping the invite-only screen",
307
+ "Откроется сразу с токеном доступа — без экрана «Доступ по приглашению»"),
308
+ "logs": tr("Logs", "Логи"),
309
+ "needToken": tr("RAILWAY_API_TOKEN is required: restart small dashboard with it set",
310
+ "Нужен RAILWAY_API_TOKEN: перезапустите small dashboard с ним"),
311
+ "costAtLoad": tr("≈ {cost}/mo at current load", "≈ {cost}/мес при текущей нагрузке"),
312
+ "costHint": tr("Average load over the past week (CPU, memory, volumes; databases included) × Railway prices. "
313
+ "Excludes egress and the plan's free allowance — see the exact amount in Railway → Usage",
314
+ "Средняя нагрузка за неделю (CPU, память, тома; вместе с базами) × тарифы Railway. "
315
+ "Без трафика и бесплатного лимита тарифа — точная сумма в Railway → Usage"),
316
+ "variables": tr("Variables:", "Переменные:"),
317
+ "logsTitle": tr("Logs · {app}", "Логи · {app}"),
318
+ "loading": tr("Loading…", "Загрузка…"),
319
+ "noDeploys": tr("This service has no deploys yet", "У сервиса ещё нет деплоев"),
320
+ "logsMeta": tr("Deploy {id} · {status} · lines: {count}", "Деплой {id} · {status} · строк: {count}"),
321
+ "logEmpty": tr("Log is empty", "Лог пуст"),
322
+ "logsFailed": tr("Couldn't load logs: {error}", "Не удалось получить лог: {error}"),
323
+ "live": {
324
+ "on": tr("deploy statuses from Railway", "статусы деплоя из Railway"),
325
+ "off": tr("offline mode: set RAILWAY_API_TOKEN for live statuses",
326
+ "офлайн-режим: задайте RAILWAY_API_TOKEN для живых статусов"),
327
+ "error": tr("Railway didn't respond, showing local data", "Railway не ответил, показаны локальные данные"),
328
+ },
329
+ "perMonth": tr("/mo", "/мес"),
330
+ "apps": tr(["app", "apps"], ["приложение", "приложения", "приложений"]),
331
+ "summary": tr("{count} {apps} · {linked} linked to Railway{total} · updated at {time}",
332
+ "{count} {apps} · {linked} привязано к Railway{total} · обновлено в {time}"),
333
+ "summaryEmpty": tr("Updated at {time}", "Обновлено в {time}"),
334
+ "listFailed": tr("Couldn't load the app list: {error}. Is small dashboard running?",
335
+ "Не удалось получить список: {error}. Запущен ли small dashboard?"),
336
+ }
337
+
338
+
339
+ def render_page():
340
+ """HTML дашборда на текущем языке: тексты разметки подставляются в {{name}}, тексты для JS — объектом T."""
341
+ code = '<code class="font-mono">%s</code>'
342
+ values = {
343
+ "lang": lang(),
344
+ "apps_in": tr("Micro-apps in", "Микро-приложения в"),
345
+ "refresh": tr("Refresh", "Обновить"),
346
+ "no_apps": tr("No apps found", "Приложений не найдено"),
347
+ "no_app_folders": tr("There are no folders with %s or %s here.", "Здесь нет папок с %s или %s.")
348
+ % (code % "small.json", code % ".small.json"),
349
+ "create_app": tr("Create an app: %s", "Создайте приложение: %s") % (code % "small init my-app"),
350
+ "tab_app": tr("App", "Приложение"),
351
+ "tab_build": tr("Build", "Сборка"),
352
+ "close": tr("Close", "Закрыть"),
353
+ # JSON внутри <script>: "</" экранируем, чтобы "</script>" в строке не закрыл тег раньше времени.
354
+ "texts": json.dumps(_page_texts(), ensure_ascii=False).replace("</", "<\\/"),
355
+ }
356
+ return re.sub(r"\{\{(\w+)\}\}", lambda m: values[m.group(1)], PAGE_TEMPLATE)
357
+
358
+
359
+ # Внутри страницы комментариев нет: она уходит в браузер и на английском, поэтому заметки к ней — здесь.
360
+ # - У #logs нет display-класса: Tailwind-класс вроде "flex" перебил бы атрибут hidden, и окно было бы видно сразу.
361
+ # - DEPLOY_STATUS — тон бейджа живого статуса последнего деплоя из Railway; подпись к статусу — в T.deploy.
362
+ # - ARROW вставляется через insertAdjacentHTML: это константа, не данные пользователя.
363
+ # - Клик по #logs или #logs-backdrop — клик по затемнённому фону вокруг окна: окно закрывается.
364
+ # - fmt() подставляет {name} за один проход: подставленные значения (сообщения об ошибках) повторно не разбираются.
365
+ PAGE_TEMPLATE = r"""<!doctype html>
366
+ <html lang="{{lang}}">
367
+ <head>
368
+ <meta charset="utf-8">
369
+ <meta name="viewport" content="width=device-width, initial-scale=1">
370
+ <title>small dashboard</title>
371
+ <script src="https://cdn.tailwindcss.com/3.4.17"></script>
372
+ </head>
373
+ <body class="min-h-screen bg-stone-50 text-stone-900 antialiased dark:bg-stone-950 dark:text-stone-100">
374
+ <main class="mx-auto max-w-4xl px-4 py-10">
375
+ <header class="mb-6 flex flex-wrap items-end justify-between gap-4">
376
+ <div class="min-w-0">
377
+ <h1 class="text-2xl font-semibold tracking-tight">small dashboard</h1>
378
+ <p class="mt-1 truncate text-sm text-stone-500 dark:text-stone-400">
379
+ {{apps_in}} <span id="root" class="font-mono"></span>
380
+ </p>
381
+ </div>
382
+ <button id="refresh" type="button"
383
+ class="inline-flex items-center gap-2 rounded-lg bg-stone-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-stone-700 disabled:cursor-wait disabled:opacity-60 dark:bg-stone-100 dark:text-stone-900 dark:hover:bg-white">
384
+ <svg id="spinner" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
385
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
386
+ <path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/>
387
+ </svg>
388
+ {{refresh}}
389
+ </button>
390
+ </header>
391
+
392
+ <p id="summary" class="mb-4 text-sm text-stone-500 dark:text-stone-400"></p>
393
+ <ul id="apps" class="grid gap-3"></ul>
394
+
395
+ <div id="empty" hidden class="rounded-xl border border-dashed border-stone-300 p-10 text-center dark:border-stone-700">
396
+ <p class="font-medium">{{no_apps}}</p>
397
+ <p class="mt-1 text-sm text-stone-500 dark:text-stone-400">
398
+ {{no_app_folders}}
399
+ {{create_app}}
400
+ </p>
401
+ </div>
402
+ <div id="error" hidden class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300"></div>
403
+ </main>
404
+
405
+ <div id="logs" hidden class="fixed inset-0 z-10 bg-black/50">
406
+ <div id="logs-backdrop" class="flex h-full items-center justify-center p-4">
407
+ <div class="flex max-h-[85vh] w-full max-w-4xl flex-col overflow-hidden rounded-xl bg-white shadow-xl dark:bg-stone-900">
408
+ <div class="flex flex-wrap items-center gap-2 border-b border-stone-200 px-4 py-3 dark:border-stone-800">
409
+ <h2 id="logs-title" class="mr-auto min-w-0 truncate font-semibold"></h2>
410
+ <div class="inline-flex rounded-lg bg-stone-100 p-0.5 text-sm dark:bg-stone-800" role="tablist">
411
+ <button type="button" data-build="0" class="logs-tab rounded-md px-3 py-1">{{tab_app}}</button>
412
+ <button type="button" data-build="1" class="logs-tab rounded-md px-3 py-1">{{tab_build}}</button>
413
+ </div>
414
+ <button id="logs-refresh" type="button" class="rounded-lg px-3 py-1 text-sm hover:bg-stone-100 dark:hover:bg-stone-800">{{refresh}}</button>
415
+ <button id="logs-close" type="button" aria-label="{{close}}" class="rounded-lg px-2 py-1 text-lg leading-none hover:bg-stone-100 dark:hover:bg-stone-800">×</button>
416
+ </div>
417
+ <p id="logs-meta" class="px-4 pt-2 text-xs text-stone-500 dark:text-stone-400"></p>
418
+ <pre id="logs-body" class="m-0 flex-1 overflow-auto whitespace-pre-wrap break-all px-4 py-3 font-mono text-xs leading-relaxed"></pre>
419
+ </div>
420
+ </div>
421
+ </div>
422
+
423
+ <script>
424
+ const T = {{texts}};
425
+ const $ = (id) => document.getElementById(id);
426
+
427
+ function el(tag, className, text) {
428
+ const node = document.createElement(tag);
429
+ if (className) node.className = className;
430
+ if (text != null) node.textContent = text;
431
+ return node;
432
+ }
433
+
434
+ function fmt(template, values) {
435
+ return template.replace(/\{(\w+)\}/g, (match, key) => (key in values ? String(values[key]) : match));
436
+ }
437
+
438
+ function plural(n, forms) {
439
+ if (forms.length < 3) return n === 1 ? forms[0] : forms[1];
440
+ const [one, few, many] = forms;
441
+ const m10 = n % 10, m100 = n % 100;
442
+ if (m10 === 1 && m100 !== 11) return one;
443
+ if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
444
+ return many;
445
+ }
446
+
447
+ const STATUS = {
448
+ linked: [T.linked, "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-950 dark:text-emerald-300 dark:ring-emerald-400/30", "bg-emerald-500"],
449
+ not_deployed: [T.notDeployed, "bg-stone-100 text-stone-600 ring-stone-500/20 dark:bg-stone-800 dark:text-stone-300 dark:ring-stone-400/20", "bg-stone-400"],
450
+ };
451
+
452
+ const DEPLOY_TONES = {
453
+ ok: ["bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-950 dark:text-emerald-300 dark:ring-emerald-400/30", "bg-emerald-500"],
454
+ busy: ["bg-amber-50 text-amber-800 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-300 dark:ring-amber-400/30", "bg-amber-500 animate-pulse"],
455
+ bad: ["bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-950 dark:text-red-300 dark:ring-red-400/30", "bg-red-500"],
456
+ idle: ["bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-950 dark:text-sky-300 dark:ring-sky-400/30", "bg-sky-500"],
457
+ other: ["bg-stone-100 text-stone-600 ring-stone-500/20 dark:bg-stone-800 dark:text-stone-300 dark:ring-stone-400/20", "bg-stone-400"],
458
+ };
459
+ const DEPLOY_STATUS = {
460
+ SUCCESS: "ok", SLEEPING: "idle",
461
+ QUEUED: "busy", WAITING: "busy", INITIALIZING: "busy", BUILDING: "busy", DEPLOYING: "busy",
462
+ FAILED: "bad", CRASHED: "bad",
463
+ REMOVED: "other", REMOVING: "other", SKIPPED: "other", NEEDS_APPROVAL: "other",
464
+ };
465
+
466
+ function deployBadge(deployment) {
467
+ const tone = DEPLOY_STATUS[deployment.status] || "other";
468
+ const hint = T.deploy[deployment.status] || "";
469
+ const [badgeClass, dotClass] = DEPLOY_TONES[tone];
470
+ const badge = el("span", "inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 font-mono text-xs font-medium ring-1 ring-inset " + badgeClass);
471
+ badge.append(el("span", "h-1.5 w-1.5 rounded-full " + dotClass), document.createTextNode(deployment.status));
472
+ const when = deployment.createdAt ? new Date(deployment.createdAt).toLocaleString(T.locale) : "";
473
+ badge.title = T.lastDeploy + (hint ? ": " + hint : "") + (when ? ", " + when : "");
474
+ return badge;
475
+ }
476
+
477
+ const ARROW ='<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
478
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17 17 7"/><path d="M8 7h9v9"/></svg>';
479
+
480
+ function link(href, text) {
481
+ const a = el("a", "inline-flex items-center gap-1 font-medium text-blue-600 hover:underline dark:text-blue-400", text);
482
+ a.insertAdjacentHTML("beforeend", ARROW);
483
+ a.href = href;
484
+ a.target = "_blank";
485
+ a.rel = "noopener noreferrer";
486
+ return a;
487
+ }
488
+
489
+ function card(app) {
490
+ const li = el("li", "rounded-xl border border-stone-200 bg-white p-5 shadow-sm dark:border-stone-800 dark:bg-stone-900");
491
+ const head = el("div", "flex flex-wrap items-center justify-between gap-2");
492
+ head.append(el("h2", "text-base font-semibold", app.name));
493
+ const [label, badgeClass, dotClass] = STATUS[app.status] || STATUS.not_deployed;
494
+ const badge = el("span", "inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset " + badgeClass);
495
+ badge.append(el("span", "h-1.5 w-1.5 rounded-full " + dotClass), document.createTextNode(label));
496
+ const badges = el("div", "flex flex-wrap items-center gap-2");
497
+ if (app.sleep) {
498
+ const sleep = el("span", "inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-xs font-medium text-sky-700 " +
499
+ "ring-1 ring-inset ring-sky-600/20 dark:bg-sky-950 dark:text-sky-300 dark:ring-sky-400/30", T.sleepBadge);
500
+ sleep.title = T.sleepHint;
501
+ badges.append(sleep);
502
+ }
503
+ if (app.deployment) badges.append(deployBadge(app.deployment));
504
+ badges.append(badge);
505
+ head.append(badges);
506
+ li.append(head);
507
+
508
+ const links = el("div", "mt-3 flex flex-wrap gap-x-5 gap-y-1 text-sm");
509
+ if (app.url) links.append(link(app.url, app.url.replace("https://", "")));
510
+ if (app.railwayUrl) links.append(link(app.railwayUrl, "Railway"));
511
+ if (!app.url && !app.railwayUrl) links.append(el("span", "text-stone-500 dark:text-stone-400", T.notDeployedYet));
512
+ li.append(links);
513
+
514
+ if (app.url || app.serviceId) {
515
+ const actions = el("div", "mt-3 flex flex-wrap gap-2");
516
+ if (app.url) {
517
+ const open = el("a", BUTTON, T.open);
518
+ open.href = "/open?app=" + encodeURIComponent(app.relPath);
519
+ open.target = "_blank";
520
+ open.rel = "noopener";
521
+ open.title = T.openHint;
522
+ actions.append(open);
523
+ }
524
+ if (app.serviceId) {
525
+ const logs = el("button", BUTTON, T.logs);
526
+ logs.type = "button";
527
+ if (liveMode === "off") {
528
+ logs.disabled = true;
529
+ logs.title = T.needToken;
530
+ }
531
+ logs.addEventListener("click", () => openLogs(app));
532
+ actions.append(logs);
533
+ }
534
+ li.append(actions);
535
+ }
536
+
537
+ if (app.costUsd !== null && app.costUsd !== undefined) {
538
+ const cost = el("p", "mt-3 text-xs text-stone-500 dark:text-stone-400", fmt(T.costAtLoad, { cost: usd(app.costUsd) }));
539
+ cost.title = T.costHint;
540
+ li.append(cost);
541
+ }
542
+ li.append(el("p", "mt-3 break-all font-mono text-xs text-stone-500 dark:text-stone-400", app.path));
543
+
544
+ if (app.envNames.length) {
545
+ const env = el("div", "mt-3 flex flex-wrap items-center gap-1.5");
546
+ env.append(el("span", "text-xs text-stone-500 dark:text-stone-400", T.variables));
547
+ for (const name of app.envNames) {
548
+ env.append(el("span", "rounded bg-stone-100 px-1.5 py-0.5 font-mono text-xs dark:bg-stone-800", name));
549
+ }
550
+ li.append(env);
551
+ }
552
+ for (const error of app.errors) {
553
+ li.append(el("p", "mt-3 rounded-md bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:bg-amber-950 dark:text-amber-300", "⚠ " + error));
554
+ }
555
+ return li;
556
+ }
557
+
558
+ function usd(value) {
559
+ return value < 0.01 ? "< $0.01" : "$" + value.toFixed(2);
560
+ }
561
+
562
+ const BUTTON = "inline-flex items-center rounded-lg border border-stone-300 px-3 py-1.5 text-sm font-medium transition " +
563
+ "hover:bg-stone-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-stone-700 dark:hover:bg-stone-800";
564
+ let liveMode = "off";
565
+ const logsState = { app: null, build: false };
566
+
567
+ function openLogs(app) {
568
+ logsState.app = app;
569
+ logsState.build = false;
570
+ $("logs-title").textContent = fmt(T.logsTitle, { app: app.name });
571
+ $("logs").hidden = false;
572
+ loadLogs();
573
+ }
574
+
575
+ async function loadLogs() {
576
+ const { app, build } = logsState;
577
+ for (const tab of document.querySelectorAll(".logs-tab")) {
578
+ const active = (tab.dataset.build === "1") === build;
579
+ tab.className = "logs-tab rounded-md px-3 py-1 " + (active ? "bg-white shadow-sm dark:bg-stone-700" : "text-stone-500 dark:text-stone-400");
580
+ }
581
+ $("logs-meta").textContent = T.loading;
582
+ $("logs-body").textContent = "";
583
+ try {
584
+ const res = await fetch("/api/logs?app=" + encodeURIComponent(app.relPath) + "&build=" + (build ? 1 : 0), { cache: "no-store" });
585
+ const data = await res.json();
586
+ if (!res.ok) throw new Error(data.error || "HTTP " + res.status);
587
+ if (!data.deployment) {
588
+ $("logs-meta").textContent = T.noDeploys;
589
+ return;
590
+ }
591
+ $("logs-meta").textContent = fmt(T.logsMeta,
592
+ { id: data.deployment.id, status: data.deployment.status, count: data.lines.length });
593
+ $("logs-body").textContent = data.lines.length
594
+ ? data.lines.map((l) => new Date(l.time).toLocaleTimeString(T.locale) + " " + l.message).join("\n")
595
+ : T.logEmpty;
596
+ $("logs-body").scrollTop = $("logs-body").scrollHeight;
597
+ } catch (e) {
598
+ $("logs-meta").textContent = fmt(T.logsFailed, { error: e.message });
599
+ }
600
+ }
601
+
602
+ for (const tab of document.querySelectorAll(".logs-tab")) {
603
+ tab.addEventListener("click", () => { logsState.build = tab.dataset.build === "1"; loadLogs(); });
604
+ }
605
+ $("logs-refresh").addEventListener("click", loadLogs);
606
+ $("logs-close").addEventListener("click", () => { $("logs").hidden = true; });
607
+ $("logs").addEventListener("click", (e) => {
608
+ if (e.target === $("logs") || e.target === $("logs-backdrop")) $("logs").hidden = true;
609
+ });
610
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("logs").hidden = true; });
611
+
612
+ async function refresh() {
613
+ const button = $("refresh");
614
+ button.disabled = true;
615
+ $("spinner").classList.add("animate-spin");
616
+ try {
617
+ const res = await fetch("/api/apps", { cache: "no-store" });
618
+ if (!res.ok) throw new Error("HTTP " + res.status);
619
+ const data = await res.json();
620
+ liveMode = data.live;
621
+ $("root").textContent = data.root;
622
+ $("apps").replaceChildren(...data.apps.map(card));
623
+ $("empty").hidden = data.apps.length > 0;
624
+ $("error").hidden = true;
625
+ const n = data.apps.length;
626
+ const linked = data.apps.filter((a) => a.status === "linked").length;
627
+ const time = new Date(data.scannedAt).toLocaleTimeString(T.locale);
628
+ const live = T.live[data.live] || "";
629
+ const costs = [...new Map(data.apps.filter((a) => a.costUsd != null).map((a) => [a.projectId, a.costUsd])).values()];
630
+ const total = costs.length ? " · ≈ " + usd(costs.reduce((x, y) => x + y, 0)) + T.perMonth : "";
631
+ $("summary").textContent = (n
632
+ ? fmt(T.summary, { count: n, apps: plural(n, T.apps), linked, total, time })
633
+ : fmt(T.summaryEmpty, { time })) + (linked && live ? " · " + live : "");
634
+ } catch (e) {
635
+ $("error").textContent = fmt(T.listFailed, { error: e.message });
636
+ $("error").hidden = false;
637
+ } finally {
638
+ button.disabled = false;
639
+ $("spinner").classList.remove("animate-spin");
640
+ }
641
+ }
642
+
643
+ $("refresh").addEventListener("click", refresh);
644
+ refresh();
645
+ </script>
646
+ </body>
647
+ </html>
648
+ """