chankhavu commited on
Commit
adf0ffd
·
verified ·
1 Parent(s): 478cb64

Add file transfer (push/pull, <=100MB); dropdown allow_custom_value

Browse files
Files changed (2) hide show
  1. __pycache__/app.cpython-311.pyc +0 -0
  2. app.py +162 -10
__pycache__/app.cpython-311.pyc CHANGED
Binary files a/__pycache__/app.cpython-311.pyc and b/__pycache__/app.cpython-311.pyc differ
 
app.py CHANGED
@@ -23,6 +23,9 @@ second so a leaked HF read-token alone can't drive your shells.
23
  import os
24
  import time
25
  import uuid
 
 
 
26
  import threading
27
  from collections import defaultdict
28
 
@@ -32,17 +35,29 @@ SECRET = os.environ.get("RELAY_SECRET", "") # set this as a Space secre
32
  CLIENT_STALE_AFTER = 30 # seconds w/o a poll => "offline"
33
  MAX_OUTPUT_CHARS = 200_000 # cap stored output per command
34
  MAX_HISTORY_PER_CLIENT = 200 # ring-buffer of commands
 
 
 
 
35
 
36
  LOCK = threading.Lock()
37
  CLIENTS: dict[str, dict] = {} # cid -> {last_seen, meta}
38
  COMMANDS: dict[str, dict] = {} # command_id -> record
39
  BY_CLIENT: dict[str, list[str]] = defaultdict(list) # cid -> [command_id, ...]
 
40
 
41
 
42
  def _now() -> float:
43
  return time.time()
44
 
45
 
 
 
 
 
 
 
 
46
  def _check(secret: str):
47
  if SECRET and secret != SECRET:
48
  raise gr.Error("bad secret")
@@ -82,9 +97,15 @@ def api_poll(client_id: str, secret: str):
82
  rec["acknowledged_at"] = _now()
83
  out.append({
84
  "id": rec["id"],
85
- "session": rec["session"],
86
- "command": rec["command"],
87
- "timeout": rec["timeout"],
 
 
 
 
 
 
88
  })
89
  return {"commands": out, "ts": _now()}
90
 
@@ -110,6 +131,43 @@ def api_result(command_id: str, client_id: str, secret: str,
110
  return {"ok": True}
111
 
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  # --------------------------------------------------------------------------- #
114
  # Human UI helpers #
115
  # --------------------------------------------------------------------------- #
@@ -151,6 +209,65 @@ def ui_send(client_label: str, session: str, command: str, timeout: int):
151
  return f"➡️ sent to `{cid}` [{session}]", ""
152
 
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  _STATUS_ICON = {"pending": "⏳", "acknowledged": "📨", "done": "✅"}
155
 
156
 
@@ -191,9 +308,10 @@ def ui_refresh(client_label: str):
191
  return gr.update(choices=choices, value=keep), ui_history(keep or "")
192
 
193
 
194
- def ui_tick(client_label: str, last_choices: list[str], last_hist: str):
 
195
  """Timer handler that only emits updates when something actually changed,
196
- so the dropdown and output don't repaint (flicker) every tick."""
197
  choices = _client_choices()
198
  if choices == last_choices:
199
  dd_out = gr.skip() # dropdown unchanged -> leave it alone
@@ -204,7 +322,9 @@ def ui_tick(client_label: str, last_choices: list[str], last_hist: str):
204
  label_for_hist = keep or ""
205
  hist = ui_history(label_for_hist)
206
  hist_out = gr.skip() if hist == last_hist else hist
207
- return dd_out, hist_out, choices, hist
 
 
208
 
209
 
210
  # --------------------------------------------------------------------------- #
@@ -220,7 +340,7 @@ with gr.Blocks(title="NII Relay", theme=gr.themes.Soft()) as demo:
220
 
221
  with gr.Row():
222
  client_dd = gr.Dropdown(label="Container", choices=_client_choices(),
223
- interactive=True, scale=3)
224
  refresh_btn = gr.Button("↻ Refresh", scale=1)
225
 
226
  with gr.Row():
@@ -235,9 +355,28 @@ with gr.Blocks(title="NII Relay", theme=gr.themes.Soft()) as demo:
235
 
236
  history_md = gr.Markdown("_Select a container to see its command history._")
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  # remember what we last pushed, so the timer can skip no-op repaints
239
  last_choices = gr.State([])
240
  last_hist = gr.State("")
 
241
 
242
  timer = gr.Timer(3.0)
243
 
@@ -248,8 +387,11 @@ with gr.Blocks(title="NII Relay", theme=gr.themes.Soft()) as demo:
248
  [send_status, command_tb])
249
  refresh_btn.click(ui_refresh, [client_dd], [client_dd, history_md])
250
  client_dd.change(ui_history, [client_dd], history_md)
251
- timer.tick(ui_tick, [client_dd, last_choices, last_hist],
252
- [client_dd, history_md, last_choices, last_hist])
 
 
 
253
 
254
  # --- hidden machine API endpoints (called by the daemon) ---
255
  with gr.Row(visible=False):
@@ -260,17 +402,27 @@ with gr.Blocks(title="NII Relay", theme=gr.themes.Soft()) as demo:
260
  a_session = gr.Textbox()
261
  a_exit = gr.Number()
262
  a_stdout = gr.Textbox()
 
 
 
 
263
  a_out = gr.JSON()
264
  b_reg = gr.Button()
265
  b_poll = gr.Button()
266
  b_res = gr.Button()
 
 
267
  b_reg.click(api_register, [a_cid, a_secret, a_meta], a_out, api_name="register")
268
  b_poll.click(api_poll, [a_cid, a_secret], a_out, api_name="poll")
269
  b_res.click(api_result, [a_cmdid, a_cid, a_secret, a_exit, a_stdout, a_session],
270
  a_out, api_name="result")
 
 
 
271
 
272
 
273
  if __name__ == "__main__":
274
  demo.queue(default_concurrency_limit=16).launch(
275
- server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860))
 
276
  )
 
23
  import os
24
  import time
25
  import uuid
26
+ import shutil
27
+ import tempfile
28
+ import pathlib
29
  import threading
30
  from collections import defaultdict
31
 
 
35
  CLIENT_STALE_AFTER = 30 # seconds w/o a poll => "offline"
36
  MAX_OUTPUT_CHARS = 200_000 # cap stored output per command
37
  MAX_HISTORY_PER_CLIENT = 200 # ring-buffer of commands
38
+ MAX_FILE_BYTES = 100 * 1024 * 1024 # 100 MB cap on transfers
39
+
40
+ FILES_DIR = pathlib.Path(tempfile.gettempdir()) / "relay_files"
41
+ FILES_DIR.mkdir(exist_ok=True)
42
 
43
  LOCK = threading.Lock()
44
  CLIENTS: dict[str, dict] = {} # cid -> {last_seen, meta}
45
  COMMANDS: dict[str, dict] = {} # command_id -> record
46
  BY_CLIENT: dict[str, list[str]] = defaultdict(list) # cid -> [command_id, ...]
47
+ PUT_FILES: dict[str, dict] = {} # file_id -> {path, filename} (server->client payloads)
48
 
49
 
50
  def _now() -> float:
51
  return time.time()
52
 
53
 
54
+ def _human(n: float) -> str:
55
+ for unit in ("B", "KB", "MB", "GB"):
56
+ if n < 1024 or unit == "GB":
57
+ return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
58
+ n /= 1024
59
+
60
+
61
  def _check(secret: str):
62
  if SECRET and secret != SECRET:
63
  raise gr.Error("bad secret")
 
97
  rec["acknowledged_at"] = _now()
98
  out.append({
99
  "id": rec["id"],
100
+ "type": rec.get("type", "exec"),
101
+ "session": rec.get("session", "main"),
102
+ "command": rec.get("command", ""),
103
+ "timeout": rec.get("timeout", 120),
104
+ # file-transfer fields (None for plain exec commands)
105
+ "file_id": rec.get("file_id"),
106
+ "dest": rec.get("dest"),
107
+ "remote_path": rec.get("remote_path"),
108
+ "filename": rec.get("filename"),
109
  })
110
  return {"commands": out, "ts": _now()}
111
 
 
131
  return {"ok": True}
132
 
133
 
134
+ def api_fetch_file(file_id: str, secret: str):
135
+ """Daemon calls this to download a server->client (push) payload."""
136
+ _check(secret)
137
+ info = PUT_FILES.get(file_id)
138
+ if not info or not os.path.isfile(info["path"]):
139
+ raise gr.Error("unknown or expired file_id")
140
+ return info["path"]
141
+
142
+
143
+ def api_upload_file(command_id: str, client_id: str, secret: str, fileobj, error: str):
144
+ """Daemon calls this to post the result of a client->server (pull) request.
145
+ `fileobj` is the uploaded file path on success, or None when `error` is set."""
146
+ _check(secret)
147
+ with LOCK:
148
+ rec = COMMANDS.get(command_id)
149
+ if rec is None: # relay restarted — keep the file anyway
150
+ rec = COMMANDS[command_id] = {
151
+ "id": command_id, "client_id": client_id, "session": "file",
152
+ "type": "get", "command": "(file fetch — relay restarted)",
153
+ "created": _now(), "status": "pending",
154
+ }
155
+ BY_CLIENT[client_id].append(command_id)
156
+ rec["status"] = "done"
157
+ rec["finished_at"] = _now()
158
+ _touch_client(client_id)
159
+ if fileobj:
160
+ fname = os.path.basename(fileobj)
161
+ saved = FILES_DIR / f"{command_id}__{fname}"
162
+ shutil.copy(fileobj, saved)
163
+ sz = saved.stat().st_size
164
+ rec.update(stored_path=str(saved), filename=fname, size=sz,
165
+ exit_code=0, stdout=f"fetched {fname} ({_human(sz)})")
166
+ else:
167
+ rec.update(exit_code=1, stdout=(error or "fetch failed"))
168
+ return {"ok": True}
169
+
170
+
171
  # --------------------------------------------------------------------------- #
172
  # Human UI helpers #
173
  # --------------------------------------------------------------------------- #
 
209
  return f"➡️ sent to `{cid}` [{session}]", ""
210
 
211
 
212
+ def ui_put_file(client_label: str, fileobj, dest: str):
213
+ """Queue a push: send an uploaded file to the selected container."""
214
+ cid = _strip_dot(client_label)
215
+ if not cid:
216
+ return "⚠️ pick a container first", None
217
+ if not fileobj:
218
+ return "⚠️ choose a file to send", None
219
+ sz = os.path.getsize(fileobj)
220
+ if sz > MAX_FILE_BYTES:
221
+ return f"⚠️ file is {_human(sz)} — over the {_human(MAX_FILE_BYTES)} limit", None
222
+ fname = os.path.basename(fileobj)
223
+ file_id = uuid.uuid4().hex
224
+ saved = FILES_DIR / f"{file_id}__{fname}"
225
+ shutil.copy(fileobj, saved)
226
+ dest = (dest or "").strip() or fname
227
+ cmd_id = uuid.uuid4().hex
228
+ with LOCK:
229
+ PUT_FILES[file_id] = {"path": str(saved), "filename": fname}
230
+ COMMANDS[cmd_id] = {
231
+ "id": cmd_id, "client_id": cid, "session": "file", "type": "put",
232
+ "file_id": file_id, "dest": dest, "filename": fname, "size": sz,
233
+ "command": f"📤 send {fname} ({_human(sz)}) → {dest}",
234
+ "timeout": 300, "status": "pending", "created": _now(),
235
+ }
236
+ BY_CLIENT[cid].append(cmd_id)
237
+ return f"➡️ sending `{fname}` to `{cid}`:`{dest}`", None
238
+
239
+
240
+ def ui_get_file(client_label: str, remote_path: str):
241
+ """Queue a pull: ask the container for a file at `remote_path`."""
242
+ cid = _strip_dot(client_label)
243
+ if not cid:
244
+ return "⚠️ pick a container first"
245
+ remote_path = (remote_path or "").strip()
246
+ if not remote_path:
247
+ return "⚠️ enter a path on the container"
248
+ cmd_id = uuid.uuid4().hex
249
+ with LOCK:
250
+ COMMANDS[cmd_id] = {
251
+ "id": cmd_id, "client_id": cid, "session": "file", "type": "get",
252
+ "remote_path": remote_path, "command": f"📥 fetch {remote_path}",
253
+ "timeout": 300, "status": "pending", "created": _now(),
254
+ }
255
+ BY_CLIENT[cid].append(cmd_id)
256
+ return f"➡️ requested `{remote_path}` from `{cid}`"
257
+
258
+
259
+ def _fetched_files(client_label: str) -> list[str]:
260
+ cid = _strip_dot(client_label)
261
+ paths = []
262
+ with LOCK:
263
+ for cmd_id in BY_CLIENT.get(cid, []):
264
+ r = COMMANDS.get(cmd_id)
265
+ if r and r.get("type") == "get" and r.get("stored_path") \
266
+ and os.path.isfile(r["stored_path"]):
267
+ paths.append(r["stored_path"])
268
+ return paths
269
+
270
+
271
  _STATUS_ICON = {"pending": "⏳", "acknowledged": "📨", "done": "✅"}
272
 
273
 
 
308
  return gr.update(choices=choices, value=keep), ui_history(keep or "")
309
 
310
 
311
+ def ui_tick(client_label: str, last_choices: list[str], last_hist: str,
312
+ last_files: list[str]):
313
  """Timer handler that only emits updates when something actually changed,
314
+ so the dropdown, output, and downloads don't repaint (flicker) every tick."""
315
  choices = _client_choices()
316
  if choices == last_choices:
317
  dd_out = gr.skip() # dropdown unchanged -> leave it alone
 
322
  label_for_hist = keep or ""
323
  hist = ui_history(label_for_hist)
324
  hist_out = gr.skip() if hist == last_hist else hist
325
+ files = _fetched_files(label_for_hist)
326
+ files_out = gr.skip() if files == last_files else gr.update(value=files)
327
+ return dd_out, hist_out, files_out, choices, hist, files
328
 
329
 
330
  # --------------------------------------------------------------------------- #
 
340
 
341
  with gr.Row():
342
  client_dd = gr.Dropdown(label="Container", choices=_client_choices(),
343
+ interactive=True, allow_custom_value=True, scale=3)
344
  refresh_btn = gr.Button("↻ Refresh", scale=1)
345
 
346
  with gr.Row():
 
355
 
356
  history_md = gr.Markdown("_Select a container to see its command history._")
357
 
358
+ with gr.Accordion("📁 File transfer (≤ 100 MB)", open=False):
359
+ with gr.Row():
360
+ with gr.Column():
361
+ gr.Markdown("**Send to container** (push)")
362
+ put_file = gr.File(label="file", type="filepath")
363
+ put_dest = gr.Textbox(label="destination path on container",
364
+ placeholder="/mnt/data/ or /mnt/data/foo.bin")
365
+ put_btn = gr.Button("Send ⬆", variant="primary")
366
+ put_status = gr.Markdown("")
367
+ with gr.Column():
368
+ gr.Markdown("**Fetch from container** (pull)")
369
+ get_path = gr.Textbox(label="path on container",
370
+ placeholder="/mnt/data/train.log")
371
+ get_btn = gr.Button("Fetch ⬇", variant="primary")
372
+ get_status = gr.Markdown("")
373
+ downloads = gr.Files(label="fetched files (click to download)",
374
+ interactive=False)
375
+
376
  # remember what we last pushed, so the timer can skip no-op repaints
377
  last_choices = gr.State([])
378
  last_hist = gr.State("")
379
+ last_files = gr.State([])
380
 
381
  timer = gr.Timer(3.0)
382
 
 
387
  [send_status, command_tb])
388
  refresh_btn.click(ui_refresh, [client_dd], [client_dd, history_md])
389
  client_dd.change(ui_history, [client_dd], history_md)
390
+ client_dd.change(_fetched_files, [client_dd], downloads)
391
+ put_btn.click(ui_put_file, [client_dd, put_file, put_dest], [put_status, put_file])
392
+ get_btn.click(ui_get_file, [client_dd, get_path], get_status)
393
+ timer.tick(ui_tick, [client_dd, last_choices, last_hist, last_files],
394
+ [client_dd, history_md, downloads, last_choices, last_hist, last_files])
395
 
396
  # --- hidden machine API endpoints (called by the daemon) ---
397
  with gr.Row(visible=False):
 
402
  a_session = gr.Textbox()
403
  a_exit = gr.Number()
404
  a_stdout = gr.Textbox()
405
+ a_fileid = gr.Textbox()
406
+ a_err = gr.Textbox()
407
+ a_file_in = gr.File()
408
+ a_file_out = gr.File()
409
  a_out = gr.JSON()
410
  b_reg = gr.Button()
411
  b_poll = gr.Button()
412
  b_res = gr.Button()
413
+ b_fetch = gr.Button()
414
+ b_upload = gr.Button()
415
  b_reg.click(api_register, [a_cid, a_secret, a_meta], a_out, api_name="register")
416
  b_poll.click(api_poll, [a_cid, a_secret], a_out, api_name="poll")
417
  b_res.click(api_result, [a_cmdid, a_cid, a_secret, a_exit, a_stdout, a_session],
418
  a_out, api_name="result")
419
+ b_fetch.click(api_fetch_file, [a_fileid, a_secret], a_file_out, api_name="fetch_file")
420
+ b_upload.click(api_upload_file, [a_cmdid, a_cid, a_secret, a_file_in, a_err],
421
+ a_out, api_name="upload_result")
422
 
423
 
424
  if __name__ == "__main__":
425
  demo.queue(default_concurrency_limit=16).launch(
426
+ server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)),
427
+ max_file_size="150mb",
428
  )