devs-webadmin 2.0.12__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.
- devs_webadmin/__init__.py +10 -0
- devs_webadmin/api/__init__.py +0 -0
- devs_webadmin/api/routes.py +412 -0
- devs_webadmin/app.py +26 -0
- devs_webadmin/cli.py +38 -0
- devs_webadmin/config.py +43 -0
- devs_webadmin/static/index.html +755 -0
- devs_webadmin-2.0.12.dist-info/METADATA +165 -0
- devs_webadmin-2.0.12.dist-info/RECORD +12 -0
- devs_webadmin-2.0.12.dist-info/WHEEL +5 -0
- devs_webadmin-2.0.12.dist-info/entry_points.txt +2 -0
- devs_webadmin-2.0.12.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Web Admin UI for DevContainer Management."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("devs-webadmin")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
__author__ = "Dan Lester"
|
|
10
|
+
__email__ = "dan@ideonate.com"
|
|
File without changes
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""API routes for webadmin."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from devs_common.core.container import ContainerManager
|
|
14
|
+
from devs_common.core.workspace import WorkspaceManager
|
|
15
|
+
from devs_common.core.project import Project
|
|
16
|
+
from devs_common.utils.repo_cache import RepoCache
|
|
17
|
+
from devs_common.utils.docker_client import DockerClient
|
|
18
|
+
from devs_common.devs_config import DevsConfigLoader
|
|
19
|
+
from devs_common.exceptions import DevsError
|
|
20
|
+
|
|
21
|
+
from ..config import config
|
|
22
|
+
|
|
23
|
+
logger = structlog.get_logger()
|
|
24
|
+
|
|
25
|
+
router = APIRouter(prefix="/api")
|
|
26
|
+
|
|
27
|
+
# Track running tunnel auth processes: container_name -> subprocess.Popen
|
|
28
|
+
_auth_processes: Dict[str, subprocess.Popen] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StartRequest(BaseModel):
|
|
32
|
+
repo: str # org/repo format
|
|
33
|
+
dev_name: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ContainerActionRequest(BaseModel):
|
|
37
|
+
container_name: str # Docker container name from list
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_repo_project(repo: str) -> Project:
|
|
41
|
+
"""Clone/update a repo via cache and return a Project for it."""
|
|
42
|
+
repo_cache = RepoCache(cache_dir=config.repo_cache_dir)
|
|
43
|
+
repo_path = repo_cache.ensure_repo(repo)
|
|
44
|
+
return Project(project_dir=repo_path)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/containers")
|
|
48
|
+
async def list_containers(repo: Optional[str] = None) -> dict:
|
|
49
|
+
"""List containers, optionally filtered by repo."""
|
|
50
|
+
try:
|
|
51
|
+
if repo:
|
|
52
|
+
project = await asyncio.to_thread(_get_repo_project, repo)
|
|
53
|
+
container_manager = ContainerManager(project, config)
|
|
54
|
+
containers = await asyncio.to_thread(container_manager.list_containers)
|
|
55
|
+
else:
|
|
56
|
+
containers = await asyncio.to_thread(ContainerManager.list_all_containers)
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"containers": [
|
|
60
|
+
{
|
|
61
|
+
"name": c.name,
|
|
62
|
+
"dev_name": c.dev_name,
|
|
63
|
+
"project_name": c.project_name,
|
|
64
|
+
"status": c.status,
|
|
65
|
+
"container_id": c.container_id,
|
|
66
|
+
"created": c.created.isoformat() if c.created else None,
|
|
67
|
+
"mode": "live" if c.labels.get("devs.live") == "true" else "copy",
|
|
68
|
+
}
|
|
69
|
+
for c in containers
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
except DevsError as e:
|
|
73
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.post("/start")
|
|
77
|
+
async def start_container(request: StartRequest) -> dict:
|
|
78
|
+
"""Start a named devcontainer for a repo."""
|
|
79
|
+
try:
|
|
80
|
+
project = await asyncio.to_thread(_get_repo_project, request.repo)
|
|
81
|
+
container_manager = ContainerManager(project, config)
|
|
82
|
+
workspace_manager = WorkspaceManager(project, config)
|
|
83
|
+
|
|
84
|
+
devs_env = DevsConfigLoader.load_env_vars(request.dev_name, project.info.name)
|
|
85
|
+
extra_env = devs_env if devs_env else None
|
|
86
|
+
|
|
87
|
+
workspace_dir = await asyncio.to_thread(
|
|
88
|
+
workspace_manager.create_workspace, request.dev_name
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
success = await asyncio.to_thread(
|
|
92
|
+
container_manager.ensure_container_running,
|
|
93
|
+
request.dev_name,
|
|
94
|
+
workspace_dir,
|
|
95
|
+
extra_env=extra_env,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if success:
|
|
99
|
+
return {
|
|
100
|
+
"status": "started",
|
|
101
|
+
"repo": request.repo,
|
|
102
|
+
"dev_name": request.dev_name,
|
|
103
|
+
}
|
|
104
|
+
else:
|
|
105
|
+
raise HTTPException(status_code=500, detail="Failed to start container")
|
|
106
|
+
|
|
107
|
+
except DevsError as e:
|
|
108
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _stop_by_name(container_name: str, remove: bool) -> bool:
|
|
112
|
+
"""Stop (and optionally remove) a container by its Docker name."""
|
|
113
|
+
docker = DockerClient()
|
|
114
|
+
try:
|
|
115
|
+
docker.stop_container(container_name)
|
|
116
|
+
except Exception:
|
|
117
|
+
return False
|
|
118
|
+
if remove:
|
|
119
|
+
try:
|
|
120
|
+
docker.remove_container(container_name)
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
return True
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _clean_workspace(project_name: str, dev_name: str) -> None:
|
|
127
|
+
"""Remove workspace for a project/dev combination."""
|
|
128
|
+
# Build workspace path directly from project name + dev name
|
|
129
|
+
workspace_name = f"{project_name}-{dev_name}"
|
|
130
|
+
workspace_dir = config.workspaces_dir / workspace_name
|
|
131
|
+
if workspace_dir.exists():
|
|
132
|
+
import shutil
|
|
133
|
+
shutil.rmtree(workspace_dir)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@router.post("/stop")
|
|
137
|
+
async def stop_container(request: ContainerActionRequest) -> dict:
|
|
138
|
+
"""Stop a container by Docker name (preserves state)."""
|
|
139
|
+
success = await asyncio.to_thread(_stop_by_name, request.container_name, False)
|
|
140
|
+
|
|
141
|
+
if not success:
|
|
142
|
+
raise HTTPException(
|
|
143
|
+
status_code=404,
|
|
144
|
+
detail=f"Container {request.container_name} not found or already stopped",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return {"status": "stopped", "container_name": request.container_name}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@router.post("/clean")
|
|
151
|
+
async def clean_container(request: ContainerActionRequest) -> dict:
|
|
152
|
+
"""Stop, remove container and clean workspace by Docker name."""
|
|
153
|
+
# Get container labels before removing so we can find the workspace
|
|
154
|
+
docker = DockerClient()
|
|
155
|
+
dev_name = None
|
|
156
|
+
project_name = None
|
|
157
|
+
try:
|
|
158
|
+
containers = docker.find_containers_by_labels({"devs.managed": "true"})
|
|
159
|
+
for c in containers:
|
|
160
|
+
if c["name"] == request.container_name:
|
|
161
|
+
dev_name = c["labels"].get("devs.dev")
|
|
162
|
+
project_name = c["labels"].get("devs.project")
|
|
163
|
+
break
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
# Stop and remove the container
|
|
168
|
+
await asyncio.to_thread(_stop_by_name, request.container_name, True)
|
|
169
|
+
|
|
170
|
+
# Clean workspace if we found the labels
|
|
171
|
+
if dev_name and project_name:
|
|
172
|
+
await asyncio.to_thread(_clean_workspace, project_name, dev_name)
|
|
173
|
+
|
|
174
|
+
return {"status": "cleaned", "container_name": request.container_name}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- Tunnel helpers ---
|
|
178
|
+
|
|
179
|
+
def _get_container_labels(container_name: str) -> dict:
|
|
180
|
+
"""Get labels for a container by Docker name."""
|
|
181
|
+
docker = DockerClient()
|
|
182
|
+
containers = docker.find_containers_by_labels({"devs.managed": "true"})
|
|
183
|
+
for c in containers:
|
|
184
|
+
if c["name"] == container_name:
|
|
185
|
+
return c.get("labels", {})
|
|
186
|
+
return {}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _make_tunnel_name(container_name: str) -> str:
|
|
190
|
+
"""Derive tunnel name from container name (max 20 chars).
|
|
191
|
+
|
|
192
|
+
Container names are like dev-ideonate-devs-sally.
|
|
193
|
+
Tunnel name uses the same convention, truncated to 20 chars
|
|
194
|
+
keeping the dev name suffix.
|
|
195
|
+
"""
|
|
196
|
+
name = container_name.replace(".", "-").replace("_", "-")
|
|
197
|
+
if len(name) <= 20:
|
|
198
|
+
return name
|
|
199
|
+
# Keep the last segment (dev name) and truncate the rest
|
|
200
|
+
parts = name.rsplit("-", 1)
|
|
201
|
+
if len(parts) == 2:
|
|
202
|
+
suffix = f"-{parts[1]}"
|
|
203
|
+
budget = 20 - len(suffix)
|
|
204
|
+
if budget >= 3:
|
|
205
|
+
return parts[0][:budget].rstrip("-") + suffix
|
|
206
|
+
return name[:20]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _docker_exec(container_name: str, cmd: list, timeout: int = 10) -> subprocess.CompletedProcess:
|
|
210
|
+
"""Run a docker exec command."""
|
|
211
|
+
full_cmd = ["docker", "exec", container_name] + cmd
|
|
212
|
+
return subprocess.run(full_cmd, capture_output=True, text=True, timeout=timeout)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# --- Tunnel endpoints ---
|
|
216
|
+
|
|
217
|
+
@router.get("/tunnel/status")
|
|
218
|
+
async def tunnel_status(container_name: str) -> dict:
|
|
219
|
+
"""Get VS Code tunnel status for a container."""
|
|
220
|
+
try:
|
|
221
|
+
result = await asyncio.to_thread(
|
|
222
|
+
_docker_exec, container_name,
|
|
223
|
+
["/usr/local/bin/code", "tunnel", "status"]
|
|
224
|
+
)
|
|
225
|
+
is_running = result.returncode == 0
|
|
226
|
+
tunnel_name = _make_tunnel_name(container_name)
|
|
227
|
+
return {
|
|
228
|
+
"running": is_running,
|
|
229
|
+
"message": result.stdout.strip() if is_running else (result.stderr.strip() or "No tunnel running"),
|
|
230
|
+
"tunnel_name": tunnel_name,
|
|
231
|
+
}
|
|
232
|
+
except subprocess.TimeoutExpired:
|
|
233
|
+
return {"running": False, "message": "Status check timed out"}
|
|
234
|
+
except Exception as e:
|
|
235
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@router.post("/tunnel/start")
|
|
239
|
+
async def tunnel_start(request: ContainerActionRequest) -> dict:
|
|
240
|
+
"""Start a VS Code tunnel in a container (background)."""
|
|
241
|
+
container_name = request.container_name
|
|
242
|
+
tunnel_name = _make_tunnel_name(container_name)
|
|
243
|
+
log_file = "/tmp/vscode-tunnel.log"
|
|
244
|
+
tunnel_cmd = f"/usr/local/bin/code tunnel --accept-server-license-terms --name {tunnel_name}"
|
|
245
|
+
|
|
246
|
+
def _start() -> dict:
|
|
247
|
+
# Start tunnel in background
|
|
248
|
+
subprocess.run(
|
|
249
|
+
["docker", "exec", "-d", container_name,
|
|
250
|
+
"/bin/sh", "-c", f"{tunnel_cmd} > {log_file} 2>&1"],
|
|
251
|
+
check=True,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# Poll for startup (up to 15s)
|
|
255
|
+
import time
|
|
256
|
+
for i in range(15):
|
|
257
|
+
time.sleep(1)
|
|
258
|
+
result = subprocess.run(
|
|
259
|
+
["docker", "exec", container_name, "cat", log_file],
|
|
260
|
+
capture_output=True, text=True,
|
|
261
|
+
)
|
|
262
|
+
output = result.stdout
|
|
263
|
+
|
|
264
|
+
if "Open this link" in output or "vscode.dev/tunnel" in output:
|
|
265
|
+
return {
|
|
266
|
+
"status": "running",
|
|
267
|
+
"tunnel_name": tunnel_name,
|
|
268
|
+
"web_url": f"https://vscode.dev/tunnel/{tunnel_name}",
|
|
269
|
+
"vscode_cmd": f"code --remote tunnel+{tunnel_name}",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if "log in" in output.lower() or "device" in output.lower() or "invalid" in output.lower():
|
|
273
|
+
# Auth needed - kill the failed attempt
|
|
274
|
+
subprocess.run(
|
|
275
|
+
["docker", "exec", container_name, "/usr/local/bin/code", "tunnel", "kill"],
|
|
276
|
+
capture_output=True,
|
|
277
|
+
)
|
|
278
|
+
return {"status": "auth_required", "tunnel_name": tunnel_name}
|
|
279
|
+
|
|
280
|
+
# Timeout - tunnel may still be starting
|
|
281
|
+
return {
|
|
282
|
+
"status": "starting",
|
|
283
|
+
"tunnel_name": tunnel_name,
|
|
284
|
+
"message": "Tunnel may still be starting, check status",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
return await asyncio.to_thread(_start)
|
|
289
|
+
except Exception as e:
|
|
290
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@router.post("/tunnel/kill")
|
|
294
|
+
async def tunnel_kill(request: ContainerActionRequest) -> dict:
|
|
295
|
+
"""Kill a running VS Code tunnel."""
|
|
296
|
+
try:
|
|
297
|
+
result = await asyncio.to_thread(
|
|
298
|
+
_docker_exec, request.container_name,
|
|
299
|
+
["/usr/local/bin/code", "tunnel", "kill"]
|
|
300
|
+
)
|
|
301
|
+
return {
|
|
302
|
+
"killed": result.returncode == 0,
|
|
303
|
+
"message": result.stdout.strip() or result.stderr.strip(),
|
|
304
|
+
}
|
|
305
|
+
except Exception as e:
|
|
306
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@router.post("/tunnel/auth")
|
|
310
|
+
async def tunnel_auth_start(request: ContainerActionRequest) -> dict:
|
|
311
|
+
"""Start tunnel auth (GitHub device flow).
|
|
312
|
+
|
|
313
|
+
Launches the login command, captures the device URL + code,
|
|
314
|
+
and returns them so the user can complete auth in their browser.
|
|
315
|
+
The process keeps running until auth completes or is cancelled.
|
|
316
|
+
"""
|
|
317
|
+
container_name = request.container_name
|
|
318
|
+
|
|
319
|
+
# Kill any existing auth process for this container
|
|
320
|
+
if container_name in _auth_processes:
|
|
321
|
+
try:
|
|
322
|
+
_auth_processes[container_name].kill()
|
|
323
|
+
except Exception:
|
|
324
|
+
pass
|
|
325
|
+
del _auth_processes[container_name]
|
|
326
|
+
|
|
327
|
+
def _start_auth() -> dict:
|
|
328
|
+
proc = subprocess.Popen(
|
|
329
|
+
["docker", "exec", container_name,
|
|
330
|
+
"/usr/local/bin/code", "tunnel", "user", "login",
|
|
331
|
+
"--provider", "github"],
|
|
332
|
+
stdout=subprocess.PIPE,
|
|
333
|
+
stderr=subprocess.STDOUT,
|
|
334
|
+
text=True,
|
|
335
|
+
)
|
|
336
|
+
_auth_processes[container_name] = proc
|
|
337
|
+
|
|
338
|
+
# Read output lines looking for the device URL and code
|
|
339
|
+
output_lines = []
|
|
340
|
+
device_url = None
|
|
341
|
+
device_code = None
|
|
342
|
+
import time
|
|
343
|
+
deadline = time.time() + 30 # 30s timeout for initial output
|
|
344
|
+
|
|
345
|
+
while time.time() < deadline:
|
|
346
|
+
line = proc.stdout.readline()
|
|
347
|
+
if not line:
|
|
348
|
+
if proc.poll() is not None:
|
|
349
|
+
break # Process exited
|
|
350
|
+
continue
|
|
351
|
+
output_lines.append(line.strip())
|
|
352
|
+
full_output = " ".join(output_lines)
|
|
353
|
+
|
|
354
|
+
# Look for GitHub device flow URL and code
|
|
355
|
+
url_match = re.search(r'(https://github\.com/login/device)', full_output)
|
|
356
|
+
if url_match:
|
|
357
|
+
device_url = url_match.group(1)
|
|
358
|
+
|
|
359
|
+
code_match = re.search(r'code\s+([A-Z0-9]{4}-[A-Z0-9]{4})', full_output, re.IGNORECASE)
|
|
360
|
+
if code_match:
|
|
361
|
+
device_code = code_match.group(1)
|
|
362
|
+
|
|
363
|
+
# If we have both, return immediately (process keeps running)
|
|
364
|
+
if device_url and device_code:
|
|
365
|
+
return {
|
|
366
|
+
"status": "waiting_for_browser",
|
|
367
|
+
"device_url": device_url,
|
|
368
|
+
"device_code": device_code,
|
|
369
|
+
"message": f"Open {device_url} and enter code {device_code}",
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
# Process may have exited successfully (already authed)
|
|
373
|
+
if proc.poll() is not None and proc.returncode == 0:
|
|
374
|
+
if container_name in _auth_processes:
|
|
375
|
+
del _auth_processes[container_name]
|
|
376
|
+
return {"status": "already_authenticated"}
|
|
377
|
+
|
|
378
|
+
# Timeout or couldn't parse
|
|
379
|
+
if container_name in _auth_processes:
|
|
380
|
+
try:
|
|
381
|
+
proc.kill()
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
del _auth_processes[container_name]
|
|
385
|
+
return {
|
|
386
|
+
"status": "error",
|
|
387
|
+
"message": "Could not get device code. Output: " + "\n".join(output_lines[-5:]),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
return await asyncio.to_thread(_start_auth)
|
|
392
|
+
except Exception as e:
|
|
393
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@router.get("/tunnel/auth/status")
|
|
397
|
+
async def tunnel_auth_status(container_name: str) -> dict:
|
|
398
|
+
"""Check if a pending tunnel auth has completed."""
|
|
399
|
+
proc = _auth_processes.get(container_name)
|
|
400
|
+
if proc is None:
|
|
401
|
+
return {"status": "no_pending_auth"}
|
|
402
|
+
|
|
403
|
+
poll = proc.poll()
|
|
404
|
+
if poll is None:
|
|
405
|
+
return {"status": "waiting_for_browser"}
|
|
406
|
+
|
|
407
|
+
# Process finished
|
|
408
|
+
del _auth_processes[container_name]
|
|
409
|
+
if poll == 0:
|
|
410
|
+
return {"status": "authenticated"}
|
|
411
|
+
else:
|
|
412
|
+
return {"status": "failed", "message": f"Auth process exited with code {poll}"}
|
devs_webadmin/app.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""FastAPI application for devs web admin."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from fastapi.staticfiles import StaticFiles
|
|
7
|
+
from fastapi.responses import FileResponse
|
|
8
|
+
|
|
9
|
+
from .api.routes import router as api_router
|
|
10
|
+
|
|
11
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
12
|
+
|
|
13
|
+
app = FastAPI(
|
|
14
|
+
title="Devs Web Admin",
|
|
15
|
+
description="Web UI for managing devcontainers on a server",
|
|
16
|
+
version="0.1.0",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
app.include_router(api_router)
|
|
20
|
+
|
|
21
|
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.get("/")
|
|
25
|
+
async def index() -> FileResponse:
|
|
26
|
+
return FileResponse(str(STATIC_DIR / "index.html"))
|
devs_webadmin/cli.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""CLI entry point for webadmin."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import uvicorn
|
|
5
|
+
|
|
6
|
+
from .config import config
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group()
|
|
10
|
+
def cli() -> None:
|
|
11
|
+
"""Devs Web Admin - manage devcontainers from a web UI."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@cli.command()
|
|
16
|
+
@click.option("--host", default=None, help="Host to bind to")
|
|
17
|
+
@click.option("--port", default=None, type=int, help="Port to bind to")
|
|
18
|
+
@click.option("--reload", is_flag=True, help="Enable auto-reload for development")
|
|
19
|
+
def serve(host: str, port: int, reload: bool) -> None:
|
|
20
|
+
"""Start the web admin server."""
|
|
21
|
+
host = host or config.host
|
|
22
|
+
port = port or config.port
|
|
23
|
+
|
|
24
|
+
click.echo(f"Starting devs-webadmin on {host}:{port}")
|
|
25
|
+
uvicorn.run(
|
|
26
|
+
"devs_webadmin.app:app",
|
|
27
|
+
host=host,
|
|
28
|
+
port=port,
|
|
29
|
+
reload=reload,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main() -> None:
|
|
34
|
+
cli()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
devs_webadmin/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Configuration for webadmin package."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict
|
|
6
|
+
|
|
7
|
+
from devs_common.config import BaseConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WebAdminConfig(BaseConfig):
|
|
11
|
+
"""Configuration settings for devs web admin."""
|
|
12
|
+
|
|
13
|
+
PROJECT_PREFIX = "dev"
|
|
14
|
+
WORKSPACES_DIR = Path.home() / ".devs" / "workspaces"
|
|
15
|
+
BRIDGE_DIR = Path.home() / ".devs" / "bridge"
|
|
16
|
+
REPO_CACHE_DIR = Path.home() / ".devs" / "repocache"
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
super().__init__()
|
|
20
|
+
|
|
21
|
+
repo_cache_env = os.getenv("DEVS_REPO_CACHE_DIR")
|
|
22
|
+
self.repo_cache_dir = Path(repo_cache_env) if repo_cache_env else self.REPO_CACHE_DIR
|
|
23
|
+
|
|
24
|
+
self.host = os.getenv("WEBADMIN_HOST", "0.0.0.0")
|
|
25
|
+
self.port = int(os.getenv("WEBADMIN_PORT", "8080"))
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def container_labels(self) -> Dict[str, str]:
|
|
29
|
+
labels = super().container_labels
|
|
30
|
+
labels["devs.source"] = "webadmin"
|
|
31
|
+
return labels
|
|
32
|
+
|
|
33
|
+
def get_default_workspaces_dir(self) -> Path:
|
|
34
|
+
return self.WORKSPACES_DIR
|
|
35
|
+
|
|
36
|
+
def get_default_bridge_dir(self) -> Path:
|
|
37
|
+
return self.BRIDGE_DIR
|
|
38
|
+
|
|
39
|
+
def get_default_project_prefix(self) -> str:
|
|
40
|
+
return self.PROJECT_PREFIX
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
config = WebAdminConfig()
|