portacode 1.4.15.dev23__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 +215 -50
- portacode/connection/terminal.py +80 -0
- {portacode-1.4.15.dev23.dist-info → portacode-1.4.16.dev3.dist-info}/METADATA +1 -1
- {portacode-1.4.15.dev23.dist-info → portacode-1.4.16.dev3.dist-info}/RECORD +11 -11
- {portacode-1.4.15.dev23.dist-info → portacode-1.4.16.dev3.dist-info}/WHEEL +0 -0
- {portacode-1.4.15.dev23.dist-info → portacode-1.4.16.dev3.dist-info}/entry_points.txt +0 -0
- {portacode-1.4.15.dev23.dist-info → portacode-1.4.16.dev3.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.4.15.dev23.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
|
-
|
|
390
|
-
def _as_int(value: Any) -> int:
|
|
391
|
-
try:
|
|
392
|
-
return int(value)
|
|
393
|
-
except (TypeError, ValueError):
|
|
394
|
-
return 0
|
|
395
490
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
@@ -607,7 +740,18 @@ def _build_bootstrap_steps(
|
|
|
607
740
|
)
|
|
608
741
|
steps.extend(
|
|
609
742
|
[
|
|
610
|
-
{
|
|
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
|
+
},
|
|
611
755
|
{
|
|
612
756
|
"name": "add_sudo",
|
|
613
757
|
"cmd": (
|
|
@@ -633,7 +777,7 @@ def _build_bootstrap_steps(
|
|
|
633
777
|
{
|
|
634
778
|
"name": "add_sudoers",
|
|
635
779
|
"cmd": (
|
|
636
|
-
f"printf '%s ALL=(ALL) NOPASSWD:ALL
|
|
780
|
+
f"printf '%s ALL=(ALL) NOPASSWD:ALL\\n' {shlex.quote(user)} >/etc/sudoers.d/portacode && "
|
|
637
781
|
"chmod 0440 /etc/sudoers.d/portacode"
|
|
638
782
|
),
|
|
639
783
|
"retries": 0,
|
|
@@ -865,12 +1009,17 @@ def _parse_ctid(message: Dict[str, Any]) -> int:
|
|
|
865
1009
|
|
|
866
1010
|
|
|
867
1011
|
def _ensure_container_managed(
|
|
868
|
-
proxmox: Any, node: str, vmid: int
|
|
1012
|
+
proxmox: Any, node: str, vmid: int, *, device_id: Optional[str] = None
|
|
869
1013
|
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
870
1014
|
record = _read_container_record(vmid)
|
|
871
1015
|
ct_cfg = proxmox.nodes(node).lxc(str(vmid)).config.get()
|
|
872
1016
|
if not ct_cfg or MANAGED_MARKER not in (ct_cfg.get("description") or ""):
|
|
873
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
|
+
)
|
|
874
1023
|
return record, ct_cfg
|
|
875
1024
|
|
|
876
1025
|
|
|
@@ -1371,6 +1520,13 @@ def _instantiate_container(proxmox: Any, node: str, payload: Dict[str, Any]) ->
|
|
|
1371
1520
|
ssh_public_keys=payload.get("ssh_public_key") or None,
|
|
1372
1521
|
)
|
|
1373
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
|
+
)
|
|
1374
1530
|
return vmid, elapsed
|
|
1375
1531
|
except ResourceException as exc:
|
|
1376
1532
|
raise RuntimeError(f"Failed to create container: {exc}") from exc
|
|
@@ -1817,10 +1973,13 @@ class StartProxmoxContainerHandler(SyncHandler):
|
|
|
1817
1973
|
|
|
1818
1974
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1819
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")
|
|
1820
1979
|
config = _ensure_infra_configured()
|
|
1821
1980
|
proxmox = _connect_proxmox(config)
|
|
1822
1981
|
node = _get_node_from_config(config)
|
|
1823
|
-
_ensure_container_managed(proxmox, node, vmid)
|
|
1982
|
+
_ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
|
|
1824
1983
|
|
|
1825
1984
|
status, elapsed = _start_container(proxmox, node, vmid)
|
|
1826
1985
|
_update_container_record(vmid, {"status": "running"})
|
|
@@ -1847,10 +2006,13 @@ class StopProxmoxContainerHandler(SyncHandler):
|
|
|
1847
2006
|
|
|
1848
2007
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1849
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")
|
|
1850
2012
|
config = _ensure_infra_configured()
|
|
1851
2013
|
proxmox = _connect_proxmox(config)
|
|
1852
2014
|
node = _get_node_from_config(config)
|
|
1853
|
-
_ensure_container_managed(proxmox, node, vmid)
|
|
2015
|
+
_ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
|
|
1854
2016
|
|
|
1855
2017
|
status, elapsed = _stop_container(proxmox, node, vmid)
|
|
1856
2018
|
final_status = status.get("status") or "stopped"
|
|
@@ -1883,10 +2045,13 @@ class RemoveProxmoxContainerHandler(SyncHandler):
|
|
|
1883
2045
|
|
|
1884
2046
|
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
1885
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")
|
|
1886
2051
|
config = _ensure_infra_configured()
|
|
1887
2052
|
proxmox = _connect_proxmox(config)
|
|
1888
2053
|
node = _get_node_from_config(config)
|
|
1889
|
-
_ensure_container_managed(proxmox, node, vmid)
|
|
2054
|
+
_ensure_container_managed(proxmox, node, vmid, device_id=child_device_id)
|
|
1890
2055
|
|
|
1891
2056
|
stop_status, stop_elapsed = _stop_container(proxmox, node, vmid)
|
|
1892
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
|