portacode 1.4.15.dev15__py3-none-any.whl → 1.4.16.dev3__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.
- portacode/_version.py +2 -2
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +31 -16
- portacode/connection/handlers/project_state/manager.py +25 -3
- portacode/connection/handlers/proxmox_infra.py +273 -61
- portacode/connection/terminal.py +80 -0
- {portacode-1.4.15.dev15.dist-info → portacode-1.4.16.dev3.dist-info}/METADATA +1 -1
- {portacode-1.4.15.dev15.dist-info → portacode-1.4.16.dev3.dist-info}/RECORD +11 -11
- {portacode-1.4.15.dev15.dist-info → portacode-1.4.16.dev3.dist-info}/WHEEL +0 -0
- {portacode-1.4.15.dev15.dist-info → portacode-1.4.16.dev3.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.15.dev15.dist-info → portacode-1.4.16.dev3.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.15.dev15.dist-info → portacode-1.4.16.dev3.dist-info}/top_level.txt +0 -0
portacode/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '1.4.
|
|
32
|
-
__version_tuple__ = version_tuple = (1, 4,
|
|
31
|
+
__version__ = version = '1.4.16.dev3'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 4, 16, 'dev3')
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -381,6 +381,7 @@ Starts a previously provisioned, Portacode-managed LXC container. Handled by [`S
|
|
|
381
381
|
**Payload Fields:**
|
|
382
382
|
|
|
383
383
|
* `ctid` (string, required): Identifier of the container to start.
|
|
384
|
+
* `child_device_id` (string, required): Dashboard `Device.id` of the container that triggered the request; the handler validates the CT belongs to that device before issuing the start.
|
|
384
385
|
|
|
385
386
|
**Responses:**
|
|
386
387
|
|
|
@@ -394,6 +395,7 @@ Stops a running Portacode-managed container. Handled by [`StopProxmoxContainerHa
|
|
|
394
395
|
**Payload Fields:**
|
|
395
396
|
|
|
396
397
|
* `ctid` (string, required): Identifier of the container to stop.
|
|
398
|
+
* `child_device_id` (string, required): Dashboard `Device.id` that owns the container; the handler rejects the request if the CT is mapped to another device.
|
|
397
399
|
|
|
398
400
|
**Responses:**
|
|
399
401
|
|
|
@@ -407,6 +409,7 @@ Deletes a managed container from Proxmox (stopping it first if necessary) and re
|
|
|
407
409
|
**Payload Fields:**
|
|
408
410
|
|
|
409
411
|
* `ctid` (string, required): Identifier of the container to delete.
|
|
412
|
+
* `child_device_id` (string, required): Dashboard `Device.id` that should own the container metadata being purged.
|
|
410
413
|
|
|
411
414
|
**Responses:**
|
|
412
415
|
|
|
@@ -1133,22 +1136,34 @@ Provides system information in response to a `system_info` action. Handled by [`
|
|
|
1133
1136
|
* `bridge` (string): The bridge interface configured (typically `vmbr1`).
|
|
1134
1137
|
* `health` (string|null): `"healthy"` when the connectivity verification succeeded.
|
|
1135
1138
|
* `node_status` (object|null): Status response returned by the Proxmox API when validating the token.
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1139
|
+
* `managed_containers` (object): Cached summary of the Portacode-managed containers:
|
|
1140
|
+
* `updated_at` (string): ISO timestamp when this snapshot was last refreshed.
|
|
1141
|
+
* `count` (integer): Number of managed containers.
|
|
1142
|
+
* `total_ram_mib` (integer): RAM footprint summed across all containers.
|
|
1143
|
+
* `total_disk_gib` (integer): Disk footprint summed across all containers.
|
|
1144
|
+
* `total_cpu_share` (number): CPU shares requested across all containers.
|
|
1145
|
+
* `containers` (array[object]): Container summaries with the following fields:
|
|
1146
|
+
* `vmid` (string|null): Numeric CT ID.
|
|
1147
|
+
* `hostname` (string|null): Hostname configured in the CT.
|
|
1148
|
+
* `template` (string|null): Template identifier used.
|
|
1149
|
+
* `storage` (string|null): Storage pool backing the rootfs.
|
|
1150
|
+
* `disk_gib` (integer): Rootfs size in GiB.
|
|
1151
|
+
* `ram_mib` (integer): Memory size in MiB.
|
|
1152
|
+
* `cpu_share` (number): vCPU-equivalent share requested at creation.
|
|
1153
|
+
* `status` (string): Lowercase lifecycle status (e.g., `running`, `stopped`, `deleted`).
|
|
1154
|
+
* `created_at` (string|null): ISO timestamp recorded when the CT was provisioned.
|
|
1155
|
+
* `managed` (boolean): `true` for Portacode-managed entries.
|
|
1156
|
+
* `unmanaged_containers` (array[object]): Facts about containers Portacode did not provision; fields mirror the managed list but are marked `managed=false`.
|
|
1157
|
+
* `unmanaged_count` (integer): Number of unmanaged containers detected on the node.
|
|
1158
|
+
* `allocated_ram_mib` (integer): Total RAM reserved by both managed and unmanaged containers.
|
|
1159
|
+
* `allocated_disk_gib` (integer): Total disk reserved by both managed and unmanaged containers.
|
|
1160
|
+
* `allocated_cpu_share` (number): Total CPU shares requested by both managed and unmanaged containers.
|
|
1161
|
+
* `available_ram_mib` (integer|null): Remaining RAM after subtracting all reservations from the host total (null when unavailable).
|
|
1162
|
+
* `available_disk_gib` (integer|null): Remaining disk GB after subtracting allocations from the host total.
|
|
1163
|
+
* `available_cpu_share` (number|null): Remaining CPU shares after allocations.
|
|
1164
|
+
* `host_total_ram_mib` (integer|null): Host memory capacity observed via Proxmox.
|
|
1165
|
+
* `host_total_disk_gib` (integer|null): Host disk capacity observed via Proxmox.
|
|
1166
|
+
* `host_total_cpu_cores` (integer|null): Number of CPU cores reported by Proxmox.
|
|
1152
1167
|
* `portacode_version` (string): Installed CLI version returned by `portacode.__version__`.
|
|
1153
1168
|
|
|
1154
1169
|
### `proxmox_infra_configured`
|
|
@@ -21,6 +21,24 @@ from .git_manager import GitManager
|
|
|
21
21
|
from .file_system_watcher import FileSystemWatcher
|
|
22
22
|
from ....logging_categories import get_categorized_logger, LogCategory
|
|
23
23
|
|
|
24
|
+
def _deterministic_file_tab_id(file_path: str) -> str:
|
|
25
|
+
try:
|
|
26
|
+
resolved = os.path.abspath(file_path)
|
|
27
|
+
mtime = int(Path(resolved).stat().st_mtime)
|
|
28
|
+
except OSError:
|
|
29
|
+
mtime = "unknown"
|
|
30
|
+
resolved = os.path.abspath(file_path)
|
|
31
|
+
return f"{resolved}:{mtime}"
|
|
32
|
+
|
|
33
|
+
def _deterministic_diff_tab_id(
|
|
34
|
+
file_path: str,
|
|
35
|
+
from_ref: str,
|
|
36
|
+
to_ref: str,
|
|
37
|
+
from_hash: Optional[str],
|
|
38
|
+
to_hash: Optional[str]
|
|
39
|
+
) -> str:
|
|
40
|
+
return f"diff:{os.path.abspath(file_path)}:{from_ref}:{to_ref}:{from_hash or ''}:{to_hash or ''}"
|
|
41
|
+
|
|
24
42
|
logger = get_categorized_logger(__name__)
|
|
25
43
|
|
|
26
44
|
# Global singleton instance
|
|
@@ -657,10 +675,10 @@ class ProjectStateManager:
|
|
|
657
675
|
# Create new file tab using tab factory
|
|
658
676
|
from ..tab_factory import get_tab_factory
|
|
659
677
|
tab_factory = get_tab_factory()
|
|
660
|
-
|
|
678
|
+
|
|
661
679
|
try:
|
|
662
680
|
logger.info(f"About to create tab for file: {file_path}")
|
|
663
|
-
new_tab = await tab_factory.create_file_tab(file_path)
|
|
681
|
+
new_tab = await tab_factory.create_file_tab(file_path, tab_id=_deterministic_file_tab_id(file_path))
|
|
664
682
|
logger.info(f"Tab created successfully, adding to project state")
|
|
665
683
|
project_state.open_tabs[tab_key] = new_tab
|
|
666
684
|
if set_active:
|
|
@@ -842,7 +860,11 @@ class ProjectStateManager:
|
|
|
842
860
|
diff_title = f"{os.path.basename(file_path)} ({' '.join(title_parts)})"
|
|
843
861
|
|
|
844
862
|
diff_tab = await tab_factory.create_diff_tab_with_title(
|
|
845
|
-
file_path,
|
|
863
|
+
file_path,
|
|
864
|
+
original_content,
|
|
865
|
+
modified_content,
|
|
866
|
+
diff_title,
|
|
867
|
+
tab_id=_deterministic_diff_tab_id(file_path, from_ref, to_ref, from_hash, to_hash),
|
|
846
868
|
diff_details=diff_details
|
|
847
869
|
)
|
|
848
870
|
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
import math
|
|
9
9
|
import os
|
|
10
|
+
import re
|
|
10
11
|
import secrets
|
|
11
12
|
import shlex
|
|
12
13
|
import shutil
|
|
@@ -18,7 +19,7 @@ import time
|
|
|
18
19
|
import threading
|
|
19
20
|
from datetime import datetime, timezone
|
|
20
21
|
from pathlib import Path
|
|
21
|
-
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
22
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
|
22
23
|
|
|
23
24
|
import platformdirs
|
|
24
25
|
|
|
@@ -201,6 +202,63 @@ def _current_time_iso() -> str:
|
|
|
201
202
|
return datetime.now(timezone.utc).isoformat()
|
|
202
203
|
|
|
203
204
|
|
|
205
|
+
def _to_int(value: Any, default: int = 0) -> int:
|
|
206
|
+
try:
|
|
207
|
+
return int(value)
|
|
208
|
+
except (TypeError, ValueError):
|
|
209
|
+
return default
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _to_float(value: Any, default: float = 0.0) -> float:
|
|
213
|
+
try:
|
|
214
|
+
return float(value)
|
|
215
|
+
except (TypeError, ValueError):
|
|
216
|
+
return default
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _calculate_available(total: Optional[float], used: float) -> Optional[float]:
|
|
220
|
+
if total is None:
|
|
221
|
+
return None
|
|
222
|
+
delta = total - used
|
|
223
|
+
return delta if delta >= 0 else 0.0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _normalize_bytes(value: Any) -> float:
|
|
227
|
+
if isinstance(value, (int, float)):
|
|
228
|
+
return float(value)
|
|
229
|
+
text = str(value or "").strip()
|
|
230
|
+
if not text:
|
|
231
|
+
return 0.0
|
|
232
|
+
match = re.match(r"(?i)^\s*([0-9]*\.?[0-9]+)\s*([kmgtp]?i?b?)?\s*$", text)
|
|
233
|
+
if not match:
|
|
234
|
+
return 0.0
|
|
235
|
+
number = match.group(1)
|
|
236
|
+
unit = (match.group(2) or "").lower()
|
|
237
|
+
try:
|
|
238
|
+
value = float(number)
|
|
239
|
+
except ValueError:
|
|
240
|
+
return 0.0
|
|
241
|
+
if unit.startswith("k"):
|
|
242
|
+
return value * 1024
|
|
243
|
+
if unit.startswith("m"):
|
|
244
|
+
return value * 1024**2
|
|
245
|
+
if unit.startswith("g"):
|
|
246
|
+
return value * 1024**3
|
|
247
|
+
if unit.startswith("t"):
|
|
248
|
+
return value * 1024**4
|
|
249
|
+
if unit.startswith("p"):
|
|
250
|
+
return value * 1024**5
|
|
251
|
+
return value
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _bytes_to_mib(value: Any) -> int:
|
|
255
|
+
return int(round(_normalize_bytes(value) / (1024**2)))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _bytes_to_gib(value: Any) -> int:
|
|
259
|
+
return int(round(_normalize_bytes(value) / (1024**3)))
|
|
260
|
+
|
|
261
|
+
|
|
204
262
|
def _parse_iso_timestamp(value: str) -> Optional[datetime]:
|
|
205
263
|
if not value:
|
|
206
264
|
return None
|
|
@@ -381,35 +439,67 @@ def _load_managed_container_records() -> List[Dict[str, Any]]:
|
|
|
381
439
|
return records
|
|
382
440
|
|
|
383
441
|
|
|
384
|
-
def
|
|
442
|
+
def _extract_host_totals(node_status: Dict[str, Any] | None) -> Tuple[Optional[int], Optional[int], Optional[int]]:
|
|
443
|
+
if not node_status:
|
|
444
|
+
return None, None, None
|
|
445
|
+
memory_total = node_status.get("memory", {}).get("total")
|
|
446
|
+
disk_total = node_status.get("disk", {}).get("total")
|
|
447
|
+
cpu_cores = node_status.get("cpuinfo", {}).get("cores")
|
|
448
|
+
host_ram = _bytes_to_mib(memory_total) if memory_total is not None else None
|
|
449
|
+
host_disk = _bytes_to_gib(disk_total) if disk_total is not None else None
|
|
450
|
+
host_cpu = _to_int(cpu_cores) if cpu_cores is not None else None
|
|
451
|
+
return host_ram, host_disk, host_cpu
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _build_unmanaged_container_entry(ct: Dict[str, Any], cfg: Dict[str, Any], vmid: str) -> Dict[str, Any]:
|
|
455
|
+
ram_mib = _to_int(cfg.get("memory")) or _bytes_to_mib(ct.get("maxmem"))
|
|
456
|
+
disk_gib = _bytes_to_gib(ct.get("maxdisk"))
|
|
457
|
+
cpu_share = _to_float(
|
|
458
|
+
cfg.get("cpulimit")
|
|
459
|
+
or cfg.get("cpus")
|
|
460
|
+
or cfg.get("cores")
|
|
461
|
+
or ct.get("cpus")
|
|
462
|
+
or ct.get("cpu")
|
|
463
|
+
)
|
|
464
|
+
hostname = ct.get("name") or cfg.get("hostname") or f"ct{vmid}"
|
|
465
|
+
storage = cfg.get("storage") or ct.get("storage")
|
|
466
|
+
status = (ct.get("status") or "unknown").lower()
|
|
467
|
+
return {
|
|
468
|
+
"vmid": vmid,
|
|
469
|
+
"hostname": hostname,
|
|
470
|
+
"template": cfg.get("ostemplate"),
|
|
471
|
+
"storage": storage,
|
|
472
|
+
"disk_gib": disk_gib,
|
|
473
|
+
"ram_mib": ram_mib,
|
|
474
|
+
"cpu_share": cpu_share,
|
|
475
|
+
"status": status,
|
|
476
|
+
"managed": False,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _build_managed_containers_summary(
|
|
482
|
+
records: List[Dict[str, Any]],
|
|
483
|
+
unmanaged_records: List[Dict[str, Any]],
|
|
484
|
+
node_status: Dict[str, Any] | None,
|
|
485
|
+
) -> Dict[str, Any]:
|
|
486
|
+
managed_containers: List[Dict[str, Any]] = []
|
|
385
487
|
total_ram = 0
|
|
386
488
|
total_disk = 0
|
|
387
489
|
total_cpu_share = 0.0
|
|
388
|
-
containers: List[Dict[str, Any]] = []
|
|
389
490
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
return 0
|
|
395
|
-
|
|
396
|
-
def _as_float(value: Any) -> float:
|
|
397
|
-
try:
|
|
398
|
-
return float(value)
|
|
399
|
-
except (TypeError, ValueError):
|
|
400
|
-
return 0.0
|
|
401
|
-
|
|
402
|
-
for record in sorted(records, key=lambda entry: _as_int(entry.get("vmid"))):
|
|
403
|
-
ram_mib = _as_int(record.get("ram_mib"))
|
|
404
|
-
disk_gib = _as_int(record.get("disk_gib"))
|
|
405
|
-
cpu_share = _as_float(record.get("cpus"))
|
|
491
|
+
for record in sorted(records, key=lambda entry: _to_int(entry.get("vmid"))):
|
|
492
|
+
ram_mib = _to_int(record.get("ram_mib"))
|
|
493
|
+
disk_gib = _to_int(record.get("disk_gib"))
|
|
494
|
+
cpu_share = _to_float(record.get("cpus"))
|
|
406
495
|
total_ram += ram_mib
|
|
407
496
|
total_disk += disk_gib
|
|
408
497
|
total_cpu_share += cpu_share
|
|
409
498
|
status = (record.get("status") or "unknown").lower()
|
|
410
|
-
|
|
499
|
+
managed_containers.append(
|
|
411
500
|
{
|
|
412
|
-
"vmid": str(
|
|
501
|
+
"vmid": str(_to_int(record.get("vmid"))) if record.get("vmid") is not None else None,
|
|
502
|
+
"device_id": record.get("device_id"),
|
|
413
503
|
"hostname": record.get("hostname"),
|
|
414
504
|
"template": record.get("template"),
|
|
415
505
|
"storage": record.get("storage"),
|
|
@@ -418,44 +508,76 @@ def _build_managed_containers_summary(records: List[Dict[str, Any]]) -> Dict[str
|
|
|
418
508
|
"cpu_share": cpu_share,
|
|
419
509
|
"created_at": record.get("created_at"),
|
|
420
510
|
"status": status,
|
|
511
|
+
"managed": True,
|
|
421
512
|
}
|
|
422
513
|
)
|
|
423
514
|
|
|
515
|
+
unmanaged_total_ram = sum(_to_int(entry.get("ram_mib")) for entry in unmanaged_records)
|
|
516
|
+
unmanaged_total_disk = sum(_to_int(entry.get("disk_gib")) for entry in unmanaged_records)
|
|
517
|
+
unmanaged_total_cpu = sum(_to_float(entry.get("cpu_share")) for entry in unmanaged_records)
|
|
518
|
+
|
|
519
|
+
allocated_ram = total_ram + unmanaged_total_ram
|
|
520
|
+
allocated_disk = total_disk + unmanaged_total_disk
|
|
521
|
+
allocated_cpu = total_cpu_share + unmanaged_total_cpu
|
|
522
|
+
|
|
523
|
+
host_ram, host_disk, host_cpu = _extract_host_totals(node_status)
|
|
524
|
+
|
|
424
525
|
return {
|
|
425
526
|
"updated_at": datetime.utcnow().isoformat() + "Z",
|
|
426
|
-
"count": len(
|
|
527
|
+
"count": len(managed_containers),
|
|
427
528
|
"total_ram_mib": total_ram,
|
|
428
529
|
"total_disk_gib": total_disk,
|
|
429
530
|
"total_cpu_share": round(total_cpu_share, 2),
|
|
430
|
-
"containers":
|
|
531
|
+
"containers": managed_containers,
|
|
532
|
+
"unmanaged_containers": unmanaged_records,
|
|
533
|
+
"unmanaged_count": len(unmanaged_records),
|
|
534
|
+
"allocated_ram_mib": allocated_ram,
|
|
535
|
+
"allocated_disk_gib": allocated_disk,
|
|
536
|
+
"allocated_cpu_share": round(allocated_cpu, 2),
|
|
537
|
+
"available_ram_mib": _calculate_available(host_ram, allocated_ram) if host_ram is not None else None,
|
|
538
|
+
"available_disk_gib": _calculate_available(host_disk, allocated_disk) if host_disk is not None else None,
|
|
539
|
+
"available_cpu_share": _calculate_available(host_cpu, allocated_cpu) if host_cpu is not None else None,
|
|
540
|
+
"host_total_ram_mib": host_ram,
|
|
541
|
+
"host_total_disk_gib": host_disk,
|
|
542
|
+
"host_total_cpu_cores": host_cpu,
|
|
431
543
|
}
|
|
432
544
|
|
|
433
545
|
|
|
434
546
|
def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
|
|
435
|
-
def _refresh_container_statuses(
|
|
436
|
-
|
|
437
|
-
|
|
547
|
+
def _refresh_container_statuses(
|
|
548
|
+
records: List[Dict[str, Any]],
|
|
549
|
+
config: Dict[str, Any] | None,
|
|
550
|
+
managed_vmids: Set[str],
|
|
551
|
+
) -> Tuple[Dict[str, str], List[Dict[str, Any]], Dict[str, Any] | None]:
|
|
552
|
+
statuses: Dict[str, str] = {}
|
|
553
|
+
unmanaged: List[Dict[str, Any]] = []
|
|
554
|
+
node_status: Dict[str, Any] | None = None
|
|
555
|
+
if not config:
|
|
556
|
+
return statuses, unmanaged, node_status
|
|
438
557
|
try:
|
|
439
558
|
proxmox = _connect_proxmox(config)
|
|
440
559
|
node = _get_node_from_config(config)
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
560
|
+
node_status = proxmox.nodes(node).status.get()
|
|
561
|
+
for ct in proxmox.nodes(node).lxc.get():
|
|
562
|
+
vmid_val = ct.get("vmid")
|
|
563
|
+
if vmid_val is None:
|
|
564
|
+
continue
|
|
565
|
+
vmid_key = str(_to_int(vmid_val))
|
|
566
|
+
statuses[vmid_key] = (ct.get("status") or "unknown").lower()
|
|
567
|
+
if vmid_key in managed_vmids:
|
|
568
|
+
continue
|
|
569
|
+
cfg: Dict[str, Any] = {}
|
|
570
|
+
try:
|
|
571
|
+
cfg = proxmox.nodes(node).lxc(vmid_key).config.get() or {}
|
|
572
|
+
except Exception as exc: # pragma: no cover - best effort
|
|
573
|
+
logger.debug("Failed to read config for container %s: %s", vmid_key, exc)
|
|
574
|
+
description = (cfg.get("description") or "")
|
|
575
|
+
if MANAGED_MARKER in description:
|
|
576
|
+
continue
|
|
577
|
+
unmanaged.append(_build_unmanaged_container_entry(ct, cfg, vmid_key))
|
|
445
578
|
except Exception as exc: # pragma: no cover - best effort
|
|
446
579
|
logger.debug("Failed to refresh container statuses: %s", exc)
|
|
447
|
-
|
|
448
|
-
for record in records:
|
|
449
|
-
vmid = record.get("vmid")
|
|
450
|
-
if vmid is None:
|
|
451
|
-
continue
|
|
452
|
-
try:
|
|
453
|
-
vmid_key = str(int(vmid))
|
|
454
|
-
except (ValueError, TypeError):
|
|
455
|
-
continue
|
|
456
|
-
status = statuses.get(vmid_key)
|
|
457
|
-
if status:
|
|
458
|
-
record["status"] = status
|
|
580
|
+
return statuses, unmanaged, node_status
|
|
459
581
|
|
|
460
582
|
now = time.monotonic()
|
|
461
583
|
with _MANAGED_CONTAINERS_CACHE_LOCK:
|
|
@@ -465,8 +587,19 @@ def _get_managed_containers_summary(force: bool = False) -> Dict[str, Any]:
|
|
|
465
587
|
return cached
|
|
466
588
|
config = _load_config()
|
|
467
589
|
records = _load_managed_container_records()
|
|
468
|
-
|
|
469
|
-
|
|
590
|
+
managed_vmids: Set[str] = {
|
|
591
|
+
str(_to_int(record.get("vmid"))) for record in records if record.get("vmid") is not None
|
|
592
|
+
}
|
|
593
|
+
statuses, unmanaged, node_status = _refresh_container_statuses(records, config, managed_vmids)
|
|
594
|
+
for record in records:
|
|
595
|
+
vmid = record.get("vmid")
|
|
596
|
+
if vmid is None:
|
|
597
|
+
continue
|
|
598
|
+
vmid_key = str(_to_int(vmid))
|
|
599
|
+
status = statuses.get(vmid_key)
|
|
600
|
+
if status:
|
|
601
|
+
record["status"] = status
|
|
602
|
+
summary = _build_managed_containers_summary(records, unmanaged, node_status)
|
|
470
603
|
with _MANAGED_CONTAINERS_CACHE_LOCK:
|
|
471
604
|
_MANAGED_CONTAINERS_CACHE["timestamp"] = now
|
|
472
605
|
_MANAGED_CONTAINERS_CACHE["summary"] = summary
|
|
@@ -496,6 +629,16 @@ def _friendly_step_label(step_name: str) -> str:
|
|
|
496
629
|
return normalized.capitalize()
|
|
497
630
|
|
|
498
631
|
|
|
632
|
+
_NETWORK_WAIT_CMD = (
|
|
633
|
+
"count=0; "
|
|
634
|
+
"while [ \"$count\" -lt 20 ]; do "
|
|
635
|
+
" if command -v ip >/dev/null 2>&1 && ip route get 1.1.1.1 >/dev/null 2>&1; then break; fi; "
|
|
636
|
+
" if [ -f /proc/net/route ] && grep -q '^00000000' /proc/net/route >/dev/null 2>&1; then break; fi; "
|
|
637
|
+
" sleep 1; "
|
|
638
|
+
" count=$((count+1)); "
|
|
639
|
+
"done"
|
|
640
|
+
)
|
|
641
|
+
|
|
499
642
|
_PACKAGE_MANAGER_PROFILES: Dict[str, Dict[str, Any]] = {
|
|
500
643
|
"apt": {
|
|
501
644
|
"update_cmd": "apt-get update -y",
|
|
@@ -524,7 +667,7 @@ _PACKAGE_MANAGER_PROFILES: Dict[str, Dict[str, Any]] = {
|
|
|
524
667
|
"apk": {
|
|
525
668
|
"update_cmd": "apk update",
|
|
526
669
|
"update_step_name": "apk_update",
|
|
527
|
-
"install_cmd": "apk add --no-cache python3 py3-pip sudo",
|
|
670
|
+
"install_cmd": "apk add --no-cache python3 py3-pip sudo shadow",
|
|
528
671
|
"install_step_name": "install_deps",
|
|
529
672
|
"update_retries": 3,
|
|
530
673
|
"install_retries": 5,
|
|
@@ -570,7 +713,9 @@ def _build_bootstrap_steps(
|
|
|
570
713
|
package_manager: str = "apt",
|
|
571
714
|
) -> List[Dict[str, Any]]:
|
|
572
715
|
profile = _PACKAGE_MANAGER_PROFILES.get(package_manager, _PACKAGE_MANAGER_PROFILES["apt"])
|
|
573
|
-
steps: List[Dict[str, Any]] = [
|
|
716
|
+
steps: List[Dict[str, Any]] = [
|
|
717
|
+
{"name": "wait_for_network", "cmd": _NETWORK_WAIT_CMD, "retries": 0},
|
|
718
|
+
]
|
|
574
719
|
update_cmd = profile.get("update_cmd")
|
|
575
720
|
if update_cmd:
|
|
576
721
|
steps.append(
|
|
@@ -595,8 +740,48 @@ def _build_bootstrap_steps(
|
|
|
595
740
|
)
|
|
596
741
|
steps.extend(
|
|
597
742
|
[
|
|
598
|
-
{
|
|
599
|
-
|
|
743
|
+
{
|
|
744
|
+
"name": "user_exists",
|
|
745
|
+
"cmd": (
|
|
746
|
+
f"id -u {user} >/dev/null 2>&1 || "
|
|
747
|
+
f"(if command -v adduser >/dev/null 2>&1 && adduser --disabled-password --help >/dev/null 2>&1; then "
|
|
748
|
+
f" adduser --disabled-password --gecos '' {user}; "
|
|
749
|
+
"else "
|
|
750
|
+
f" useradd -m -s /bin/sh {user}; "
|
|
751
|
+
"fi)"
|
|
752
|
+
),
|
|
753
|
+
"retries": 0,
|
|
754
|
+
},
|
|
755
|
+
{
|
|
756
|
+
"name": "add_sudo",
|
|
757
|
+
"cmd": (
|
|
758
|
+
f"if command -v usermod >/dev/null 2>&1; then "
|
|
759
|
+
" if ! getent group sudo >/dev/null 2>&1; then "
|
|
760
|
+
" if command -v groupadd >/dev/null 2>&1; then "
|
|
761
|
+
" groupadd sudo >/dev/null 2>&1 || true; "
|
|
762
|
+
" fi; "
|
|
763
|
+
" fi; "
|
|
764
|
+
f" usermod -aG sudo {user}; "
|
|
765
|
+
"else "
|
|
766
|
+
" for grp in wheel sudo; do "
|
|
767
|
+
" if ! getent group \"$grp\" >/dev/null 2>&1 && command -v groupadd >/dev/null 2>&1; then "
|
|
768
|
+
" groupadd \"$grp\" >/dev/null 2>&1 || true; "
|
|
769
|
+
" fi; "
|
|
770
|
+
" addgroup \"$grp\" >/dev/null 2>&1 || true; "
|
|
771
|
+
f" addgroup {user} \"$grp\" >/dev/null 2>&1 || true; "
|
|
772
|
+
" done; "
|
|
773
|
+
"fi"
|
|
774
|
+
),
|
|
775
|
+
"retries": 0,
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
"name": "add_sudoers",
|
|
779
|
+
"cmd": (
|
|
780
|
+
f"printf '%s ALL=(ALL) NOPASSWD:ALL\\n' {shlex.quote(user)} >/etc/sudoers.d/portacode && "
|
|
781
|
+
"chmod 0440 /etc/sudoers.d/portacode"
|
|
782
|
+
),
|
|
783
|
+
"retries": 0,
|
|
784
|
+
},
|
|
600
785
|
]
|
|
601
786
|
)
|
|
602
787
|
if password:
|
|
@@ -824,12 +1009,17 @@ def _parse_ctid(message: Dict[str, Any]) -> int:
|
|
|
824
1009
|
|
|
825
1010
|
|
|
826
1011
|
def _ensure_container_managed(
|
|
827
|
-
proxmox: Any, node: str, vmid: int
|
|
1012
|
+
proxmox: Any, node: str, vmid: int, *, device_id: Optional[str] = None
|
|
828
1013
|
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
829
1014
|
record = _read_container_record(vmid)
|
|
830
1015
|
ct_cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
|
|
831
1016
|
if not ct_cfg or MANAGED_MARKER not in (ct_cfg.get("description") or ""):
|
|
832
1017
|
raise RuntimeError(f"Container {vmid} is not managed by Portacode.")
|
|
1018
|
+
record_device_id = record.get("device_id")
|
|
1019
|
+
if device_id and str(record_device_id or "") != str(device_id):
|
|
1020
|
+
raise RuntimeError(
|
|
1021
|
+
f"Container {vmid} is managed for device {record_device_id!r}, not {device_id!r}."
|
|
1022
|
+
)
|
|
833
1023
|
return record, ct_cfg
|
|
834
1024
|
|
|
835
1025
|
|
|
@@ -846,7 +1036,8 @@ def _connect_proxmox(config: Dict[str, Any]) -> Any:
|
|
|
846
1036
|
|
|
847
1037
|
|
|
848
1038
|
def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str, Any]:
|
|
849
|
-
|
|
1039
|
+
shell = "/bin/sh"
|
|
1040
|
+
full = ["pct", "exec", str(vmid), "--", shell, "-c", cmd]
|
|
850
1041
|
start = time.time()
|
|
851
1042
|
proc = subprocess.run(full, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, input=input_text)
|
|
852
1043
|
return {
|
|
@@ -858,6 +1049,10 @@ def _run_pct(vmid: int, cmd: str, input_text: Optional[str] = None) -> Dict[str,
|
|
|
858
1049
|
}
|
|
859
1050
|
|
|
860
1051
|
|
|
1052
|
+
def _su_command(user: str, command: str) -> str:
|
|
1053
|
+
return f"su - {user} -s /bin/sh -c {shlex.quote(command)}"
|
|
1054
|
+
|
|
1055
|
+
|
|
861
1056
|
def _run_pct_check(vmid: int, cmd: str) -> Dict[str, Any]:
|
|
862
1057
|
res = _run_pct(vmid, cmd)
|
|
863
1058
|
if res["returncode"] != 0:
|
|
@@ -917,7 +1112,7 @@ def _push_bytes_to_container(
|
|
|
917
1112
|
|
|
918
1113
|
|
|
919
1114
|
def _resolve_portacode_key_dir(vmid: int, user: str) -> str:
|
|
920
|
-
data_dir_cmd =
|
|
1115
|
+
data_dir_cmd = _su_command(user, "echo -n ${XDG_DATA_HOME:-$HOME/.local/share}")
|
|
921
1116
|
data_home = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
|
|
922
1117
|
portacode_dir = f"{data_home}/portacode"
|
|
923
1118
|
_run_pct_exec_check(vmid, ["mkdir", "-p", portacode_dir])
|
|
@@ -934,18 +1129,19 @@ def _deploy_device_keypair(vmid: int, user: str, private_key: str, public_key: s
|
|
|
934
1129
|
|
|
935
1130
|
|
|
936
1131
|
def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -> Dict[str, Any]:
|
|
937
|
-
|
|
1132
|
+
su_connect_cmd = _su_command(user, "portacode connect")
|
|
1133
|
+
cmd = ["pct", "exec", str(vmid), "--", "/bin/sh", "-c", su_connect_cmd]
|
|
938
1134
|
proc = subprocess.Popen(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
939
1135
|
start = time.time()
|
|
940
1136
|
|
|
941
|
-
data_dir_cmd =
|
|
1137
|
+
data_dir_cmd = _su_command(user, "echo -n ${XDG_DATA_HOME:-$HOME/.local/share}")
|
|
942
1138
|
data_dir = _run_pct_check(vmid, data_dir_cmd)["stdout"].strip()
|
|
943
1139
|
key_dir = f"{data_dir}/portacode/keys"
|
|
944
1140
|
pub_path = f"{key_dir}/id_portacode.pub"
|
|
945
1141
|
priv_path = f"{key_dir}/id_portacode"
|
|
946
1142
|
|
|
947
1143
|
def file_size(path: str) -> Optional[int]:
|
|
948
|
-
stat_cmd = f"
|
|
1144
|
+
stat_cmd = _su_command(user, f"test -s {path} && stat -c %s {path}")
|
|
949
1145
|
res = _run_pct(vmid, stat_cmd)
|
|
950
1146
|
if res["returncode"] != 0:
|
|
951
1147
|
return None
|
|
@@ -1003,7 +1199,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
|
|
|
1003
1199
|
final_pub = file_size(pub_path)
|
|
1004
1200
|
final_priv = file_size(priv_path)
|
|
1005
1201
|
if final_pub and final_priv:
|
|
1006
|
-
key_res = _run_pct(vmid, f"
|
|
1202
|
+
key_res = _run_pct(vmid, _su_command(user, f"cat {pub_path}"))
|
|
1007
1203
|
if not process_exited:
|
|
1008
1204
|
proc.terminate()
|
|
1009
1205
|
try:
|
|
@@ -1043,7 +1239,7 @@ def _portacode_connect_and_read_key(vmid: int, user: str, timeout_s: int = 10) -
|
|
|
1043
1239
|
except subprocess.TimeoutExpired:
|
|
1044
1240
|
proc.kill()
|
|
1045
1241
|
|
|
1046
|
-
key_res = _run_pct(vmid, f"
|
|
1242
|
+
key_res = _run_pct(vmid, _su_command(user, f"cat {pub_path}"))
|
|
1047
1243
|
return {
|
|
1048
1244
|
"ok": True,
|
|
1049
1245
|
"public_key": key_res["stdout"].strip(),
|
|
@@ -1324,6 +1520,13 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
|
|
|
1324
1520
|
ssh_public_keys=payload.get("ssh_public_key") or None,
|
|
1325
1521
|
)
|
|
1326
1522
|
status, elapsed = _wait_for_task(proxmox, node, upid)
|
|
1523
|
+
exitstatus = (status or {}).get("exitstatus")
|
|
1524
|
+
if exitstatus and exitstatus.upper() != "OK":
|
|
1525
|
+
msg = status.get("status") or "unknown error"
|
|
1526
|
+
details = status.get("error") or status.get("errmsg") or status.get("description") or status
|
|
1527
|
+
raise RuntimeError(
|
|
1528
|
+
f"Container creation task failed ({exitstatus}): {msg} details={details}"
|
|
1529
|
+
)
|
|
1327
1530
|
return vmid, elapsed
|
|
1328
1531
|
except ResourceException as exc:
|
|
1329
1532
|
raise RuntimeError(f"Failed to create container: {exc}") from exc
|
|
@@ -1586,7 +1789,7 @@ class CreateProxmoxContainerHandler(SyncHandler):
|
|
|
1586
1789
|
on_behalf_of_device=device_id,
|
|
1587
1790
|
)
|
|
1588
1791
|
|
|
1589
|
-
cmd =
|
|
1792
|
+
cmd = _su_command(payload["username"], "sudo -S portacode service install")
|
|
1590
1793
|
res = _run_pct(vmid, cmd, input_text=payload["password"] + "\n")
|
|
1591
1794
|
|
|
1592
1795
|
if res["returncode"] != 0:
|
|
@@ -1718,7 +1921,7 @@ class StartPortacodeServiceHandler(SyncHandler):
|
|
|
1718
1921
|
on_behalf_of_device=on_behalf_of_device,
|
|
1719
1922
|
)
|
|
1720
1923
|
|
|
1721
|
-
cmd =
|
|
1924
|
+
cmd = _su_command(user, "sudo -S portacode service install")
|
|
1722
1925
|
res = _run_pct(vmid, cmd, input_text=password + "\n")
|
|
1723
1926
|
|
|
1724
1927
|
if res["returncode"] != 0:
|
|
@@ -1770,10 +1973,13 @@ class StartProxmoxContainerHandler(SyncHandler):
|
|
|
1770
1973
|
|
|
1771
1974
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1772
1975
|
vmid = _parse_ctid(message)
|
|
1976
|
+
child_device_id = (message.get("child_device_id") or "").strip()
|
|
1977
|
+
if not child_device_id:
|
|
1978
|
+
raise ValueError("child_device_id is required for start_proxmox_container")
|
|
1773
1979
|
config = _ensure_infra_configured()
|
|
1774
1980
|
proxmox = _connect_proxmox(config)
|
|
1775
1981
|
node = _get_node_from_config(config)
|
|
1776
|
-
_ensure_container_managed(proxmox, node, vmid)
|
|
1982
|
+
_ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
|
|
1777
1983
|
|
|
1778
1984
|
status, elapsed = _start_container(proxmox, node, vmid)
|
|
1779
1985
|
_update_container_record(vmid, {"status": "running"})
|
|
@@ -1800,10 +2006,13 @@ class StopProxmoxContainerHandler(SyncHandler):
|
|
|
1800
2006
|
|
|
1801
2007
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1802
2008
|
vmid = _parse_ctid(message)
|
|
2009
|
+
child_device_id = (message.get("child_device_id") or "").strip()
|
|
2010
|
+
if not child_device_id:
|
|
2011
|
+
raise ValueError("child_device_id is required for stop_proxmox_container")
|
|
1803
2012
|
config = _ensure_infra_configured()
|
|
1804
2013
|
proxmox = _connect_proxmox(config)
|
|
1805
2014
|
node = _get_node_from_config(config)
|
|
1806
|
-
_ensure_container_managed(proxmox, node, vmid)
|
|
2015
|
+
_ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
|
|
1807
2016
|
|
|
1808
2017
|
status, elapsed = _stop_container(proxmox, node, vmid)
|
|
1809
2018
|
final_status = status.get("status") or "stopped"
|
|
@@ -1836,10 +2045,13 @@ class RemoveProxmoxContainerHandler(SyncHandler):
|
|
|
1836
2045
|
|
|
1837
2046
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1838
2047
|
vmid = _parse_ctid(message)
|
|
2048
|
+
child_device_id = (message.get("child_device_id") or "").strip()
|
|
2049
|
+
if not child_device_id:
|
|
2050
|
+
raise ValueError("child_device_id is required for remove_proxmox_container")
|
|
1839
2051
|
config = _ensure_infra_configured()
|
|
1840
2052
|
proxmox = _connect_proxmox(config)
|
|
1841
2053
|
node = _get_node_from_config(config)
|
|
1842
|
-
_ensure_container_managed(proxmox, node, vmid)
|
|
2054
|
+
_ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
|
|
1843
2055
|
|
|
1844
2056
|
stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
|
|
1845
2057
|
delete_status, delete_elapsed = _delete_container(proxmox, node, vmid)
|
portacode/connection/terminal.py
CHANGED
|
@@ -685,6 +685,7 @@ class TerminalManager:
|
|
|
685
685
|
try:
|
|
686
686
|
# Initialize project state
|
|
687
687
|
project_state = await manager.initialize_project_state(session_name, project_folder_path)
|
|
688
|
+
await self._restore_tabs_from_session_metadata(manager, project_state, session)
|
|
688
689
|
|
|
689
690
|
# Send initial project state to the client
|
|
690
691
|
initial_state_payload = {
|
|
@@ -710,6 +711,85 @@ class TerminalManager:
|
|
|
710
711
|
|
|
711
712
|
except Exception as exc:
|
|
712
713
|
logger.exception("terminal_manager: Error initializing project states for new sessions: %s", exc)
|
|
714
|
+
|
|
715
|
+
async def _restore_tabs_from_session_metadata(self, manager, project_state, session):
|
|
716
|
+
"""Restore open tabs/active tab from client session metadata if available."""
|
|
717
|
+
if not session or not project_state:
|
|
718
|
+
return
|
|
719
|
+
|
|
720
|
+
descriptors = session.get("open_tabs") or []
|
|
721
|
+
if not descriptors:
|
|
722
|
+
return
|
|
723
|
+
|
|
724
|
+
session_id = project_state.client_session_id
|
|
725
|
+
logger.info("terminal_manager: 🧭 Restoring %d tabs from metadata for session %s", len(descriptors), session_id)
|
|
726
|
+
|
|
727
|
+
for descriptor in descriptors:
|
|
728
|
+
parsed = self._parse_tab_descriptor(descriptor)
|
|
729
|
+
if not parsed:
|
|
730
|
+
continue
|
|
731
|
+
|
|
732
|
+
tab_type = parsed.get("tab_type")
|
|
733
|
+
file_path = parsed.get("file_path")
|
|
734
|
+
metadata = parsed.get("metadata", {})
|
|
735
|
+
|
|
736
|
+
if tab_type == "file" and file_path:
|
|
737
|
+
try:
|
|
738
|
+
await manager.open_file(session_id, file_path, set_active=False)
|
|
739
|
+
except Exception as exc:
|
|
740
|
+
logger.warning("terminal_manager: Failed to restore file tab %s for session %s: %s", file_path, session_id, exc)
|
|
741
|
+
continue
|
|
742
|
+
|
|
743
|
+
if tab_type == "diff" and file_path:
|
|
744
|
+
from_ref = metadata.get("from") or metadata.get("from_ref")
|
|
745
|
+
to_ref = metadata.get("to") or metadata.get("to_ref")
|
|
746
|
+
if not from_ref or not to_ref:
|
|
747
|
+
logger.warning("terminal_manager: Skipping diff tab %s for session %s because from/to references are missing", file_path, session_id)
|
|
748
|
+
continue
|
|
749
|
+
from_hash = metadata.get("from_hash") or metadata.get("fromHash")
|
|
750
|
+
to_hash = metadata.get("to_hash") or metadata.get("toHash")
|
|
751
|
+
try:
|
|
752
|
+
await manager.open_diff_tab(session_id, file_path, from_ref, to_ref, from_hash=from_hash, to_hash=to_hash)
|
|
753
|
+
except Exception as exc:
|
|
754
|
+
logger.warning("terminal_manager: Failed to restore diff tab %s for session %s: %s", file_path, session_id, exc)
|
|
755
|
+
continue
|
|
756
|
+
|
|
757
|
+
logger.debug("terminal_manager: Unknown tab descriptor ignored for session %s: %s", session_id, descriptor)
|
|
758
|
+
|
|
759
|
+
active_index = session.get("active_tab")
|
|
760
|
+
try:
|
|
761
|
+
active_index_int = int(active_index) if active_index is not None else None
|
|
762
|
+
except (TypeError, ValueError):
|
|
763
|
+
active_index_int = None
|
|
764
|
+
|
|
765
|
+
if active_index_int is not None and active_index_int >= 0:
|
|
766
|
+
current_tabs = list(project_state.open_tabs.values())
|
|
767
|
+
if 0 <= active_index_int < len(current_tabs):
|
|
768
|
+
try:
|
|
769
|
+
await manager.set_active_tab(session_id, current_tabs[active_index_int].tab_id)
|
|
770
|
+
except Exception as exc:
|
|
771
|
+
logger.warning("terminal_manager: Failed to set active tab for session %s: %s", session_id, exc)
|
|
772
|
+
else:
|
|
773
|
+
logger.debug("terminal_manager: Active tab index %s out of range for session %s", active_index_int, session_id)
|
|
774
|
+
|
|
775
|
+
def _parse_tab_descriptor(self, descriptor: str) -> Optional[Dict[str, Any]]:
|
|
776
|
+
"""Parse a URL-friendly tab descriptor string."""
|
|
777
|
+
if not descriptor:
|
|
778
|
+
return None
|
|
779
|
+
|
|
780
|
+
try:
|
|
781
|
+
parts = descriptor.split("|")
|
|
782
|
+
tab_type = parts[0] if parts else None
|
|
783
|
+
file_path = parts[1] if len(parts) > 1 else None
|
|
784
|
+
metadata = {}
|
|
785
|
+
for part in parts[2:]:
|
|
786
|
+
if "=" in part:
|
|
787
|
+
key, value = part.split("=", 1)
|
|
788
|
+
metadata[key] = value
|
|
789
|
+
return {"tab_type": tab_type, "file_path": file_path, "metadata": metadata}
|
|
790
|
+
except Exception as exc:
|
|
791
|
+
logger.warning("terminal_manager: Failed to parse tab descriptor '%s': %s", descriptor, exc)
|
|
792
|
+
return None
|
|
713
793
|
|
|
714
794
|
async def _send_targeted_terminal_list(self, message: Dict[str, Any], target_sessions: List[str]) -> None:
|
|
715
795
|
"""Send terminal_list command to specific client sessions.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
|
|
2
2
|
portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
|
|
3
3
|
portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
|
|
4
|
-
portacode/_version.py,sha256=
|
|
4
|
+
portacode/_version.py,sha256=HLH9O1BtoKVO_ZLCnGRXJ_xMQe43OyaD07QzQgNnelQ,719
|
|
5
5
|
portacode/cli.py,sha256=mGLKoZ-T2FBF7IA9wUq0zyG0X9__-A1ao7gajjcVRH8,21828
|
|
6
6
|
portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
|
|
7
7
|
portacode/keypair.py,sha256=0OO4vHDcF1XMxCDqce61xFTlFwlTcmqe5HyGsXFEt7s,5838
|
|
@@ -12,9 +12,9 @@ portacode/connection/README.md,sha256=f9rbuIEKa7cTm9C98rCiBbEtbiIXQU11esGSNhSMiJ
|
|
|
12
12
|
portacode/connection/__init__.py,sha256=atqcVGkViIEd7pRa6cP2do07RJOM0UWpbnz5zXjGktU,250
|
|
13
13
|
portacode/connection/client.py,sha256=jtLb9_YufqPkzi9t8VQH3iz_JEMisbtY6a8L9U5weiU,14181
|
|
14
14
|
portacode/connection/multiplex.py,sha256=L-TxqJ_ZEbfNEfu1cwxgJ5vUdyRzZjsMy2Kx1diiZys,5237
|
|
15
|
-
portacode/connection/terminal.py,sha256=
|
|
15
|
+
portacode/connection/terminal.py,sha256=n1Uu92JacV5K6d1Qwx94Tw9OB2Tpke5HqsW2NDn76Ls,49032
|
|
16
16
|
portacode/connection/handlers/README.md,sha256=HsLZG1QK1JNm67HsgL6WoDg9nxzKXxwkc5fJPFJdX5g,12169
|
|
17
|
-
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=
|
|
17
|
+
portacode/connection/handlers/WEBSOCKET_PROTOCOL.md,sha256=U7zkUpwscWf5aqimNIP3uYXvg-t9S0MhyaZ3ygF-ADk,102593
|
|
18
18
|
portacode/connection/handlers/__init__.py,sha256=WSeBmi65GWFQPYt9M3E10rn0uZ_EPCJzNJOzSf2HZyw,2921
|
|
19
19
|
portacode/connection/handlers/base.py,sha256=oENFb-Fcfzwk99Qx8gJQriEMiwSxwygwjOiuCH36hM4,10231
|
|
20
20
|
portacode/connection/handlers/chunked_content.py,sha256=h6hXRmxSeOgnIxoU8CkmvEf2Odv-ajPrpHIe_W3GKcA,9251
|
|
@@ -22,7 +22,7 @@ portacode/connection/handlers/diff_handlers.py,sha256=iYTIRCcpEQ03vIPKZCsMTE5aZb
|
|
|
22
22
|
portacode/connection/handlers/file_handlers.py,sha256=nAJH8nXnX07xxD28ngLpgIUzcTuRwZBNpEGEKdRqohw,39507
|
|
23
23
|
portacode/connection/handlers/project_aware_file_handlers.py,sha256=AqgMnDqX2893T2NsrvUSCwjN5VKj4Pb2TN0S_SuboOE,9803
|
|
24
24
|
portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
|
|
25
|
-
portacode/connection/handlers/proxmox_infra.py,sha256=
|
|
25
|
+
portacode/connection/handlers/proxmox_infra.py,sha256=IG8qp607grx4v9jSWL5TawUrIN0VElIR2swjA_rpn9U,78132
|
|
26
26
|
portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
|
|
27
27
|
portacode/connection/handlers/session.py,sha256=uNGfiO_1B9-_yjJKkpvmbiJhIl6b-UXlT86UTfd6WYE,42219
|
|
28
28
|
portacode/connection/handlers/system_handlers.py,sha256=fr12QpOr_Z8KYGUU-AYrTQwRPAcrLK85hvj3SEq1Kw8,14757
|
|
@@ -35,7 +35,7 @@ portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL
|
|
|
35
35
|
portacode/connection/handlers/project_state/file_system_watcher.py,sha256=r9_UKxWTbzum0jGqxIafe68Ced2Y3xOp3ZmkpBOfRpw,8573
|
|
36
36
|
portacode/connection/handlers/project_state/git_manager.py,sha256=iGQ7LYIA7uHsZHdj3HEc_LYo7S1Lqv6-AeyyMwknBPo,70027
|
|
37
37
|
portacode/connection/handlers/project_state/handlers.py,sha256=qgOSt26rxAGNxW07AoevTwDPBdxblX4J-dX-EjOKtg4,38232
|
|
38
|
-
portacode/connection/handlers/project_state/manager.py,sha256=
|
|
38
|
+
portacode/connection/handlers/project_state/manager.py,sha256=ori_QpeoY1sdpY8WDYIx-kl_gNfZ7o8eq84CcOBlvIs,67915
|
|
39
39
|
portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
|
|
40
40
|
portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
|
|
41
41
|
portacode/link_capture/__init__.py,sha256=93LjyYDqzOimsIDBhsPibTl7tr-8DiIzyDF7JWQkE2A,1231
|
|
@@ -65,7 +65,7 @@ portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,3
|
|
|
65
65
|
portacode/utils/diff_apply.py,sha256=4Oi7ft3VUCKmiUE4VM-OeqO7Gk6H7PF3WnN4WHXtjxI,15157
|
|
66
66
|
portacode/utils/diff_renderer.py,sha256=S76StnQ2DLfsz4Gg0m07UwPfRp8270PuzbNaQq-rmYk,13850
|
|
67
67
|
portacode/utils/ntp_clock.py,sha256=VqCnWCTehCufE43W23oB-WUdAZGeCcLxkmIOPwInYHc,2499
|
|
68
|
-
portacode-1.4.
|
|
68
|
+
portacode-1.4.16.dev3.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
|
|
69
69
|
test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
|
|
70
70
|
test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
|
|
71
71
|
test_modules/test_device_online.py,sha256=QtYq0Dq9vME8Gp2O4fGSheqVf8LUtpsSKosXXk56gGM,1654
|
|
@@ -91,8 +91,8 @@ testing_framework/core/playwright_manager.py,sha256=Tw46qwxIhOFkS48C2IWIQHHNpEe-
|
|
|
91
91
|
testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
|
|
92
92
|
testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
|
|
93
93
|
testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
|
|
94
|
-
portacode-1.4.
|
|
95
|
-
portacode-1.4.
|
|
96
|
-
portacode-1.4.
|
|
97
|
-
portacode-1.4.
|
|
98
|
-
portacode-1.4.
|
|
94
|
+
portacode-1.4.16.dev3.dist-info/METADATA,sha256=ulkuZ2NkbFWXncNI8ZPHvWD2ThvRJ3-rFMYBNuFHpG8,13051
|
|
95
|
+
portacode-1.4.16.dev3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
96
|
+
portacode-1.4.16.dev3.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
97
|
+
portacode-1.4.16.dev3.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
98
|
+
portacode-1.4.16.dev3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|