Add staff visibility workstation enrollment registration vault and category task views
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
__version__ = "1.21.1"
|
||||
__version__ = "1.22.0"
|
||||
AGENT_NAME = "ERP Local Agent"
|
||||
|
||||
@@ -1,96 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from typing import Any
|
||||
import requests
|
||||
|
||||
from .config import AgentConfig
|
||||
|
||||
|
||||
class ERPClient:
|
||||
def __init__(self, config: AgentConfig):
|
||||
AUTH_FILE = "workstation_auth.json"
|
||||
|
||||
def __init__(self, config: AgentConfig, *, workstation: dict[str, Any] | None = None, root: Path | None = None):
|
||||
self.config = config
|
||||
self.workstation = workstation or {}
|
||||
self.root = root or Path.cwd()
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(config.headers)
|
||||
self.node_secret = self._resolve_node_secret()
|
||||
self.session.headers.update({
|
||||
"X-Node-Code": self.config.node_code,
|
||||
"X-Node-Secret": self.node_secret,
|
||||
"User-Agent": self.config.headers.get("User-Agent", "ERPLocalAgent"),
|
||||
})
|
||||
instance_id = str(self.workstation.get("agent_instance_id") or self.config.agent_instance_id or "").strip()
|
||||
if instance_id:
|
||||
self.session.headers["X-Agent-Instance-ID"] = instance_id
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
return f"{self.config.erp_base_url}{path}"
|
||||
|
||||
def heartbeat(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
response = self.session.post(
|
||||
self._url("/documents/storage-agent/heartbeat"),
|
||||
json=payload,
|
||||
timeout=self.config.request_timeout_seconds,
|
||||
def _auth_path(self) -> Path:
|
||||
path = self.root / "data" / self.AUTH_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
def _cached_workstation_secret(self) -> str:
|
||||
path = self._auth_path()
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
||||
except Exception:
|
||||
return ""
|
||||
if str(payload.get("node_code") or "") != self.config.node_code:
|
||||
return ""
|
||||
expected_instance = str(self.workstation.get("agent_instance_id") or "")
|
||||
if expected_instance and str(payload.get("agent_instance_id") or "") != expected_instance:
|
||||
return ""
|
||||
return str(payload.get("workstation_secret") or "").strip()
|
||||
|
||||
def _resolve_node_secret(self) -> str:
|
||||
if self.config.node_secret:
|
||||
return self.config.node_secret
|
||||
cached = self._cached_workstation_secret()
|
||||
if cached:
|
||||
return cached
|
||||
token = (self.config.enrollment_token or "").strip()
|
||||
if not token:
|
||||
raise RuntimeError("Local Agent has neither a node secret nor a workstation enrollment token.")
|
||||
response = requests.post(
|
||||
self._url("/documents/storage-agent/enroll-workstation"),
|
||||
json={"enrollment_token": token, "workstation": self.workstation},
|
||||
timeout=max(30, self.config.request_timeout_seconds),
|
||||
headers={"User-Agent": self.config.headers.get("User-Agent", "ERPLocalAgent")},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else {"status": "ok"}
|
||||
data = response.json()
|
||||
secret = str(data.get("workstation_secret") or "").strip()
|
||||
if not secret:
|
||||
raise RuntimeError(data.get("error") or "ERP did not return a workstation credential.")
|
||||
path = self._auth_path()
|
||||
payload = {
|
||||
"node_code": self.config.node_code,
|
||||
"agent_instance_id": str(self.workstation.get("agent_instance_id") or ""),
|
||||
"workstation_secret": secret,
|
||||
}
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
return secret
|
||||
|
||||
def heartbeat(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
response = self.session.post(self._url("/documents/storage-agent/heartbeat"), json=payload, timeout=self.config.request_timeout_seconds)
|
||||
response.raise_for_status(); return response.json() if response.content else {"status": "ok"}
|
||||
|
||||
def pending_storage_jobs(self) -> list[dict[str, Any]]:
|
||||
response = self.session.get(
|
||||
self._url("/documents/storage-agent/jobs/pending"),
|
||||
timeout=self.config.request_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return data.get("jobs", [])
|
||||
response = self.session.get(self._url("/documents/storage-agent/jobs/pending"), timeout=self.config.request_timeout_seconds)
|
||||
response.raise_for_status(); data = response.json(); return data if isinstance(data, list) else data.get("jobs", [])
|
||||
|
||||
def download_storage_job(self, job_id: int | str):
|
||||
response = self.session.get(
|
||||
self._url(f"/documents/storage-agent/jobs/{job_id}/download"),
|
||||
stream=True,
|
||||
timeout=self.config.request_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
response = self.session.get(self._url(f"/documents/storage-agent/jobs/{job_id}/download"), stream=True, timeout=self.config.request_timeout_seconds)
|
||||
response.raise_for_status(); return response
|
||||
|
||||
def acknowledge_storage_job(self, job_id: int | str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
response = self.session.post(
|
||||
self._url(f"/documents/storage-agent/jobs/{job_id}/ack"),
|
||||
json=payload,
|
||||
timeout=self.config.request_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else {"status": "ok"}
|
||||
response = self.session.post(self._url(f"/documents/storage-agent/jobs/{job_id}/ack"), json=payload, timeout=self.config.request_timeout_seconds)
|
||||
response.raise_for_status(); return response.json() if response.content else {"status": "ok"}
|
||||
|
||||
def pending_download_requests(self) -> list[dict[str, Any]]:
|
||||
response = self.session.get(
|
||||
self._url("/documents/storage-agent/download-requests/pending"),
|
||||
timeout=self.config.request_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return data.get("requests", [])
|
||||
response = self.session.get(self._url("/documents/storage-agent/download-requests/pending"), timeout=self.config.request_timeout_seconds)
|
||||
response.raise_for_status(); data = response.json(); return data if isinstance(data, list) else data.get("requests", [])
|
||||
|
||||
def upload_download_request_file(self, request_id: int | str, file_path: Path, extra: dict[str, Any]) -> dict[str, Any]:
|
||||
with file_path.open("rb") as handle:
|
||||
files = {"file": (file_path.name, handle, "application/octet-stream")}
|
||||
data = {key: str(value) for key, value in extra.items() if value is not None}
|
||||
response = self.session.post(
|
||||
self._url(f"/documents/storage-agent/download-requests/{request_id}/upload"),
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=max(self.config.request_timeout_seconds, 300),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json() if response.content else {"status": "ok"}
|
||||
|
||||
response = self.session.post(self._url(f"/documents/storage-agent/download-requests/{request_id}/upload"), files=files, data=data, timeout=max(self.config.request_timeout_seconds, 300))
|
||||
response.raise_for_status(); return response.json() if response.content else {"status": "ok"}
|
||||
|
||||
def update_manifest(self) -> dict[str, Any]:
|
||||
response = self.session.get(
|
||||
self._url("/documents/erp-local-agent/update-manifest"),
|
||||
timeout=self.config.request_timeout_seconds,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
response = self.session.get(self._url("/documents/erp-local-agent/update-manifest"), timeout=self.config.request_timeout_seconds)
|
||||
response.raise_for_status(); return response.json()
|
||||
|
||||
def download_update_package(self) -> bytes:
|
||||
response = self.session.get(
|
||||
self._url("/documents/erp-local-agent/update-package"),
|
||||
timeout=max(self.config.request_timeout_seconds, 300),
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
response = self.session.get(self._url("/documents/erp-local-agent/update-package"), timeout=max(self.config.request_timeout_seconds, 300))
|
||||
response.raise_for_status(); return response.content
|
||||
|
||||
@@ -16,6 +16,8 @@ class AgentConfig:
|
||||
node_code: str
|
||||
node_secret: str
|
||||
storage_root: Path
|
||||
enrollment_token: str | None = None
|
||||
agent_instance_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
branch_id: str | None = None
|
||||
poll_interval_seconds: int = 30
|
||||
@@ -34,11 +36,14 @@ class AgentConfig:
|
||||
|
||||
@property
|
||||
def headers(self) -> dict[str, str]:
|
||||
return {
|
||||
headers = {
|
||||
"X-Node-Code": self.node_code,
|
||||
"X-Node-Secret": self.node_secret,
|
||||
"User-Agent": f"ERPLocalAgent/{__version__}",
|
||||
}
|
||||
if self.agent_instance_id:
|
||||
headers["X-Agent-Instance-ID"] = self.agent_instance_id
|
||||
return headers
|
||||
|
||||
@property
|
||||
def tunnel_url(self) -> str:
|
||||
@@ -49,7 +54,10 @@ class AgentConfig:
|
||||
ws_base = "ws://" + base[len("http://"):]
|
||||
else:
|
||||
ws_base = base
|
||||
query = urlencode({"node_code": self.node_code, "node_secret": self.node_secret})
|
||||
query_data = {"node_code": self.node_code, "node_secret": self.node_secret}
|
||||
if self.agent_instance_id:
|
||||
query_data["agent_instance_id"] = self.agent_instance_id
|
||||
query = urlencode(query_data)
|
||||
return f"{ws_base}/documents/storage-agent/tunnel?{query}"
|
||||
|
||||
|
||||
@@ -79,6 +87,7 @@ def load_config(env_file: str | None = None) -> AgentConfig:
|
||||
erp_base_url = os.getenv("ERP_BASE_URL", "").rstrip("/")
|
||||
node_code = os.getenv("NODE_CODE", "").strip()
|
||||
node_secret = os.getenv("NODE_SECRET", "").strip()
|
||||
enrollment_token = os.getenv("ENROLLMENT_TOKEN", "").strip() or None
|
||||
storage_root_raw = os.getenv("STORAGE_ROOT", "").strip()
|
||||
tenant_id = os.getenv("TENANT_ID", os.getenv("AUDIT_FIRM_ID", "")).strip() or None
|
||||
branch_id = os.getenv("BRANCH_ID", "").strip() or None
|
||||
@@ -88,8 +97,8 @@ def load_config(env_file: str | None = None) -> AgentConfig:
|
||||
missing.append("ERP_BASE_URL")
|
||||
if not node_code:
|
||||
missing.append("NODE_CODE")
|
||||
if not node_secret:
|
||||
missing.append("NODE_SECRET")
|
||||
if not node_secret and not enrollment_token:
|
||||
missing.append("NODE_SECRET or ENROLLMENT_TOKEN")
|
||||
if not storage_root_raw:
|
||||
missing.append("STORAGE_ROOT")
|
||||
if missing:
|
||||
@@ -103,6 +112,7 @@ def load_config(env_file: str | None = None) -> AgentConfig:
|
||||
node_code=node_code,
|
||||
node_secret=node_secret,
|
||||
storage_root=storage_root,
|
||||
enrollment_token=enrollment_token,
|
||||
tenant_id=tenant_id,
|
||||
branch_id=branch_id,
|
||||
poll_interval_seconds=_get_int("POLL_INTERVAL_SECONDS", 30),
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
@@ -42,7 +43,12 @@ def main() -> int:
|
||||
db.set_meta("machine_name", workstation["machine_name"])
|
||||
db.set_meta("machine_fingerprint", workstation["machine_fingerprint"])
|
||||
db.record_event("INFO", "agent_started", f"ERP Local Agent {__version__} started")
|
||||
client = ERPClient(config)
|
||||
client = ERPClient(config, workstation=workstation, root=root)
|
||||
config = replace(
|
||||
config,
|
||||
node_secret=client.node_secret,
|
||||
agent_instance_id=workstation["agent_instance_id"],
|
||||
)
|
||||
agent = StorageAgent(config, client, db, logger)
|
||||
updater = AgentUpdater(config, client, logger, root, db=db)
|
||||
dashboard = AgentDashboard(config, db, updater, logger, root)
|
||||
|
||||
Reference in New Issue
Block a user