mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 02:05:48 -07:00
Added Log Management Page
This commit is contained in:
@@ -41,7 +41,7 @@ from .devices import routes as device_routes
|
|||||||
from .devices.approval import register_admin_endpoints
|
from .devices.approval import register_admin_endpoints
|
||||||
from .devices.management import register_management
|
from .devices.management import register_management
|
||||||
from .scheduled_jobs import management as scheduled_jobs_management
|
from .scheduled_jobs import management as scheduled_jobs_management
|
||||||
from .server import info as server_info
|
from .server import info as server_info, log_management
|
||||||
|
|
||||||
DEFAULT_API_GROUPS: Sequence[str] = ("core", "auth", "tokens", "enrollment", "devices", "server", "assemblies", "scheduled_jobs")
|
DEFAULT_API_GROUPS: Sequence[str] = ("core", "auth", "tokens", "enrollment", "devices", "server", "assemblies", "scheduled_jobs")
|
||||||
|
|
||||||
@@ -277,6 +277,7 @@ def _register_assemblies(app: Flask, adapters: EngineServiceAdapters) -> None:
|
|||||||
|
|
||||||
def _register_server(app: Flask, adapters: EngineServiceAdapters) -> None:
|
def _register_server(app: Flask, adapters: EngineServiceAdapters) -> None:
|
||||||
server_info.register_info(app, adapters)
|
server_info.register_info(app, adapters)
|
||||||
|
log_management.register_log_management(app, adapters)
|
||||||
|
|
||||||
|
|
||||||
_GROUP_REGISTRARS: Mapping[str, Callable[[Flask, EngineServiceAdapters], None]] = {
|
_GROUP_REGISTRARS: Mapping[str, Callable[[Flask, EngineServiceAdapters], None]] = {
|
||||||
|
|||||||
496
Data/Engine/services/API/server/log_management.py
Normal file
496
Data/Engine/services/API/server/log_management.py
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
# ======================================================
|
||||||
|
# Data\Engine\services\API\server\log_management.py
|
||||||
|
# Description: REST endpoints for enumerating Engine log files, viewing entries, pruning retention policies, and deleting log data.
|
||||||
|
#
|
||||||
|
# API Endpoints (if applicable):
|
||||||
|
# - GET /api/server/logs (Operator Admin Session) - Lists Engine log domains, retention policies, and metadata.
|
||||||
|
# - GET /api/server/logs/<log_name>/entries (Operator Admin Session) - Returns the most recent log lines with parsed fields for tabular display.
|
||||||
|
# - PUT /api/server/logs/retention (Operator Admin Session) - Updates per-domain log retention policies and applies pruning.
|
||||||
|
# - DELETE /api/server/logs/<log_name> (Operator Admin Session) - Deletes a specific log file or an entire log domain family (active + rotated files).
|
||||||
|
# ======================================================
|
||||||
|
|
||||||
|
"""Engine log management REST endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from collections import deque
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Deque, Dict, Iterable, List, Mapping, MutableMapping, Optional, Tuple
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from ....config import LOG_ROOT
|
||||||
|
from ...auth import RequestAuthContext
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - typing aide
|
||||||
|
from .. import EngineServiceAdapters
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_RETENTION_DAYS = 30
|
||||||
|
MAX_TAIL_LINES = 2000
|
||||||
|
SERVICE_LINE_PATTERN = re.compile(
|
||||||
|
r"^\[(?P<ts>[^\]]+)\]\s+\[(?P<level>[A-Z0-9_-]+)\](?P<context>(?:\[[^\]]+\])*)\s+(?P<msg>.*)$"
|
||||||
|
)
|
||||||
|
CONTEXT_PATTERN = re.compile(r"\[CONTEXT-([^\]]+)\]", re.IGNORECASE)
|
||||||
|
PY_LOG_PATTERN = re.compile(
|
||||||
|
r"^(?P<ts>\d{4}-\d{2}-\d{2}\s+[0-9:,]+)-(?P<logger>.+?)-(?P<level>[A-Z]+):\s*(?P<msg>.*)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_log_name(name: Optional[str]) -> Optional[str]:
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
cleaned = str(name).strip().replace("\\", "/")
|
||||||
|
if "/" in cleaned or cleaned.startswith(".") or ".." in cleaned:
|
||||||
|
return None
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _display_label(filename: str) -> str:
|
||||||
|
base = filename
|
||||||
|
if base.endswith(".log"):
|
||||||
|
base = base[:-4]
|
||||||
|
base = base.replace("_", " ").replace("-", " ").strip()
|
||||||
|
return base.title() or filename
|
||||||
|
|
||||||
|
|
||||||
|
def _stat_metadata(path: Path) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"size_bytes": 0, "modified": None}
|
||||||
|
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||||
|
return {"size_bytes": stat.st_size, "modified": modified}
|
||||||
|
|
||||||
|
|
||||||
|
def _tail_lines(path: Path, limit: int) -> Tuple[List[str], int, bool]:
|
||||||
|
"""Return the last ``limit`` lines from ``path`` and indicate truncation."""
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
lines: Deque[str] = deque(maxlen=limit)
|
||||||
|
with path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||||
|
for raw_line in handle:
|
||||||
|
count += 1
|
||||||
|
lines.append(raw_line.rstrip("\r\n"))
|
||||||
|
truncated = count > limit
|
||||||
|
return list(lines), count, truncated
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_service_line(raw: str, service_name: str) -> Dict[str, Any]:
|
||||||
|
match = SERVICE_LINE_PATTERN.match(raw)
|
||||||
|
if match:
|
||||||
|
context_block = match.group("context") or ""
|
||||||
|
context_match = CONTEXT_PATTERN.search(context_block)
|
||||||
|
scope = context_match.group(1) if context_match else None
|
||||||
|
return {
|
||||||
|
"timestamp": match.group("ts"),
|
||||||
|
"level": (match.group("level") or "").upper(),
|
||||||
|
"scope": scope,
|
||||||
|
"service": service_name,
|
||||||
|
"message": match.group("msg").strip(),
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
py_match = PY_LOG_PATTERN.match(raw)
|
||||||
|
if py_match:
|
||||||
|
return {
|
||||||
|
"timestamp": py_match.group("ts"),
|
||||||
|
"level": (py_match.group("level") or "").upper(),
|
||||||
|
"scope": None,
|
||||||
|
"service": py_match.group("logger") or service_name,
|
||||||
|
"message": py_match.group("msg").strip(),
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"timestamp": None,
|
||||||
|
"level": None,
|
||||||
|
"scope": None,
|
||||||
|
"service": service_name,
|
||||||
|
"message": raw.strip(),
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LogRetentionStore:
|
||||||
|
"""Persists log retention overrides in ``Engine/Logs``."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path, default_days: int = DEFAULT_RETENTION_DAYS) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.default_days = default_days
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def load(self) -> Dict[str, int]:
|
||||||
|
try:
|
||||||
|
with self.path.open("r", encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
overrides = data.get("overrides") if isinstance(data, dict) else None
|
||||||
|
if not isinstance(overrides, dict):
|
||||||
|
return {}
|
||||||
|
result: Dict[str, int] = {}
|
||||||
|
for key, value in overrides.items():
|
||||||
|
try:
|
||||||
|
days = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if days <= 0:
|
||||||
|
continue
|
||||||
|
canonical = _canonical_log_name(key)
|
||||||
|
if canonical:
|
||||||
|
result[canonical] = days
|
||||||
|
return result
|
||||||
|
|
||||||
|
def save(self, mapping: Mapping[str, int]) -> None:
|
||||||
|
payload = {"overrides": {key: int(value) for key, value in mapping.items() if value > 0}}
|
||||||
|
tmp_path = self.path.with_suffix(".tmp")
|
||||||
|
with tmp_path.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, indent=2)
|
||||||
|
tmp_path.replace(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
class EngineLogManager:
|
||||||
|
"""Provides filesystem-backed log enumeration and mutation helpers."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
log_root: Path,
|
||||||
|
logger: logging.Logger,
|
||||||
|
service_log: Optional[Any] = None,
|
||||||
|
) -> None:
|
||||||
|
self.log_root = log_root
|
||||||
|
self.logger = logger
|
||||||
|
self.service_log = service_log
|
||||||
|
self.log_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _resolve(self, filename: str) -> Path:
|
||||||
|
canonical = _canonical_log_name(filename)
|
||||||
|
if not canonical:
|
||||||
|
raise FileNotFoundError("Invalid log name.")
|
||||||
|
candidate = (self.log_root / canonical).resolve()
|
||||||
|
try:
|
||||||
|
candidate.relative_to(self.log_root.resolve())
|
||||||
|
except ValueError:
|
||||||
|
raise FileNotFoundError("Log path escapes log root.")
|
||||||
|
if not candidate.is_file():
|
||||||
|
raise FileNotFoundError(f"{canonical} does not exist.")
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
def _base_name(self, filename: str) -> Optional[str]:
|
||||||
|
if filename.endswith(".log"):
|
||||||
|
return filename
|
||||||
|
anchor = filename.split(".log.", 1)
|
||||||
|
if len(anchor) == 2:
|
||||||
|
return f"{anchor[0]}.log"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _family_files(self, base_name: str) -> List[Path]:
|
||||||
|
files: List[Path] = []
|
||||||
|
prefix = f"{base_name}."
|
||||||
|
for entry in self.log_root.iterdir():
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
name = entry.name
|
||||||
|
if name == base_name or name.startswith(prefix):
|
||||||
|
files.append(entry)
|
||||||
|
return files
|
||||||
|
|
||||||
|
def apply_retention(self, retention: Mapping[str, int], default_days: int) -> List[str]:
|
||||||
|
deleted: List[str] = []
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
for entry in self.log_root.iterdir():
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
base = self._base_name(entry.name)
|
||||||
|
if not base:
|
||||||
|
continue
|
||||||
|
days = retention.get(base, default_days)
|
||||||
|
if days is None or days <= 0:
|
||||||
|
continue
|
||||||
|
if entry.name == base:
|
||||||
|
# Never delete the active log via automated retention.
|
||||||
|
continue
|
||||||
|
cutoff = now - timedelta(days=days)
|
||||||
|
try:
|
||||||
|
modified = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
if modified < cutoff:
|
||||||
|
try:
|
||||||
|
entry.unlink()
|
||||||
|
deleted.append(entry.name)
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("Failed to delete expired log file: %s", entry, exc_info=True)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def domain_snapshot(self, retention: Mapping[str, int], default_days: int) -> List[Dict[str, Any]]:
|
||||||
|
domains: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for entry in sorted(self.log_root.iterdir(), key=lambda p: p.name.lower()):
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
base = self._base_name(entry.name)
|
||||||
|
if not base:
|
||||||
|
continue
|
||||||
|
domain = domains.setdefault(
|
||||||
|
base,
|
||||||
|
{
|
||||||
|
"file": base,
|
||||||
|
"display_name": _display_label(base),
|
||||||
|
"rotations": [],
|
||||||
|
"family_size_bytes": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
metadata = _stat_metadata(entry)
|
||||||
|
metadata["file"] = entry.name
|
||||||
|
domain["family_size_bytes"] += metadata.get("size_bytes", 0) or 0
|
||||||
|
if entry.name == base:
|
||||||
|
domain.update(metadata)
|
||||||
|
else:
|
||||||
|
domain["rotations"].append(metadata)
|
||||||
|
|
||||||
|
for domain in domains.values():
|
||||||
|
domain["rotations"].sort(
|
||||||
|
key=lambda meta: meta.get("modified") or "",
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
domain["rotation_count"] = len(domain["rotations"])
|
||||||
|
domain["retention_days"] = retention.get(domain["file"], default_days)
|
||||||
|
versions = [
|
||||||
|
{
|
||||||
|
"file": domain["file"],
|
||||||
|
"label": "Active",
|
||||||
|
"modified": domain.get("modified"),
|
||||||
|
"size_bytes": domain.get("size_bytes"),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
for item in domain["rotations"]:
|
||||||
|
versions.append(
|
||||||
|
{
|
||||||
|
"file": item["file"],
|
||||||
|
"label": item["file"],
|
||||||
|
"modified": item.get("modified"),
|
||||||
|
"size_bytes": item.get("size_bytes"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
domain["versions"] = versions
|
||||||
|
return sorted(domains.values(), key=lambda d: d["display_name"])
|
||||||
|
|
||||||
|
def read_entries(self, filename: str, limit: int) -> Dict[str, Any]:
|
||||||
|
path = self._resolve(filename)
|
||||||
|
service_name = _display_label(filename)
|
||||||
|
lines, count, truncated = _tail_lines(path, limit)
|
||||||
|
entries = []
|
||||||
|
start_index = max(count - len(lines), 0)
|
||||||
|
for offset, line in enumerate(lines, start=start_index):
|
||||||
|
parsed = _parse_service_line(line, service_name)
|
||||||
|
parsed["id"] = f"{filename}:{offset}"
|
||||||
|
entries.append(parsed)
|
||||||
|
stat = path.stat()
|
||||||
|
return {
|
||||||
|
"file": filename,
|
||||||
|
"entries": entries,
|
||||||
|
"total_lines": count,
|
||||||
|
"returned_lines": len(entries),
|
||||||
|
"truncated": truncated,
|
||||||
|
"size_bytes": stat.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_file(self, filename: str) -> str:
|
||||||
|
path = self._resolve(filename)
|
||||||
|
path.unlink()
|
||||||
|
return filename
|
||||||
|
|
||||||
|
def delete_family(self, filename: str) -> List[str]:
|
||||||
|
base = self._base_name(filename) or filename
|
||||||
|
canonical = _canonical_log_name(base)
|
||||||
|
if not canonical:
|
||||||
|
raise FileNotFoundError("Invalid log name.")
|
||||||
|
deleted: List[str] = []
|
||||||
|
for entry in self._family_files(canonical):
|
||||||
|
try:
|
||||||
|
entry.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug("Failed to delete family log file: %s", entry, exc_info=True)
|
||||||
|
else:
|
||||||
|
deleted.append(entry.name)
|
||||||
|
if not deleted:
|
||||||
|
raise FileNotFoundError(f"No files found for {canonical}.")
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_log_root(config: Optional[Mapping[str, Any]]) -> Path:
|
||||||
|
candidates: Iterable[Optional[str]] = ()
|
||||||
|
if config:
|
||||||
|
candidates = (
|
||||||
|
config.get("log_file"),
|
||||||
|
config.get("LOG_FILE"),
|
||||||
|
(config.get("raw") or {}).get("log_file") if isinstance(config.get("raw"), Mapping) else None,
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate:
|
||||||
|
return Path(candidate).expanduser().resolve().parent
|
||||||
|
return LOG_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def register_log_management(app, adapters: "EngineServiceAdapters") -> None:
|
||||||
|
"""Register log management endpoints."""
|
||||||
|
|
||||||
|
blueprint = Blueprint("engine_server_logs", __name__, url_prefix="/api/server/logs")
|
||||||
|
auth = RequestAuthContext(
|
||||||
|
app=app,
|
||||||
|
dev_mode_manager=adapters.dev_mode_manager,
|
||||||
|
config=adapters.config,
|
||||||
|
logger=adapters.context.logger,
|
||||||
|
)
|
||||||
|
log_root = _resolve_log_root(adapters.config)
|
||||||
|
manager = EngineLogManager(
|
||||||
|
log_root=log_root,
|
||||||
|
logger=adapters.context.logger,
|
||||||
|
service_log=adapters.service_log,
|
||||||
|
)
|
||||||
|
retention_store = LogRetentionStore(log_root / "retention_policy.json")
|
||||||
|
|
||||||
|
def _require_admin() -> Optional[Tuple[Dict[str, Any], int]]:
|
||||||
|
error = auth.require_admin()
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _audit(action: str, detail: str, *, user: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
actor = user or auth.current_user() or {}
|
||||||
|
username = actor.get("username") or "unknown"
|
||||||
|
message = f"action={action} user={username} detail={detail}"
|
||||||
|
try:
|
||||||
|
if manager.service_log:
|
||||||
|
manager.service_log("server", message, scope="ADMIN")
|
||||||
|
except Exception:
|
||||||
|
adapters.context.logger.debug("Failed to emit log management audit entry.", exc_info=True)
|
||||||
|
|
||||||
|
@blueprint.route("", methods=["GET"])
|
||||||
|
def list_logs():
|
||||||
|
error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return jsonify(error[0]), error[1]
|
||||||
|
retention = retention_store.load()
|
||||||
|
deleted = manager.apply_retention(retention, retention_store.default_days)
|
||||||
|
payload = {
|
||||||
|
"log_root": str(manager.log_root),
|
||||||
|
"logs": manager.domain_snapshot(retention, retention_store.default_days),
|
||||||
|
"default_retention_days": retention_store.default_days,
|
||||||
|
"retention_overrides": retention,
|
||||||
|
"retention_deleted": deleted,
|
||||||
|
}
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
@blueprint.route("/<path:log_name>/entries", methods=["GET"])
|
||||||
|
def log_entries(log_name: str):
|
||||||
|
error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return jsonify(error[0]), error[1]
|
||||||
|
limit_raw = request.args.get("limit")
|
||||||
|
try:
|
||||||
|
limit = min(int(limit_raw), MAX_TAIL_LINES) if limit_raw else 750
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 750
|
||||||
|
limit = max(50, min(limit, MAX_TAIL_LINES))
|
||||||
|
try:
|
||||||
|
snapshot = manager.read_entries(log_name, limit)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify({"error": "not_found", "message": "Log file not found."}), 404
|
||||||
|
return jsonify(snapshot)
|
||||||
|
|
||||||
|
@blueprint.route("/retention", methods=["PUT"])
|
||||||
|
def update_retention():
|
||||||
|
error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return jsonify(error[0]), error[1]
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
retention_payload = payload.get("retention")
|
||||||
|
parsed_updates: List[Tuple[str, Optional[int]]] = []
|
||||||
|
if isinstance(retention_payload, Mapping):
|
||||||
|
items = retention_payload.items()
|
||||||
|
elif isinstance(retention_payload, list):
|
||||||
|
items = []
|
||||||
|
for entry in retention_payload:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
items.append((entry.get("file") or entry.get("name"), entry.get("days")))
|
||||||
|
else:
|
||||||
|
items = []
|
||||||
|
for key, value in items:
|
||||||
|
canonical = _canonical_log_name(key)
|
||||||
|
if not canonical:
|
||||||
|
continue
|
||||||
|
if value in ("", False):
|
||||||
|
continue
|
||||||
|
days_value: Optional[int]
|
||||||
|
if value is None:
|
||||||
|
days_value = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
days_candidate = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
days_value = days_candidate if days_candidate > 0 else None
|
||||||
|
parsed_updates.append((canonical, days_value))
|
||||||
|
retention_map = retention_store.load()
|
||||||
|
changed = 0
|
||||||
|
for key, days in parsed_updates:
|
||||||
|
if days is None:
|
||||||
|
if key in retention_map:
|
||||||
|
retention_map.pop(key, None)
|
||||||
|
changed += 1
|
||||||
|
continue
|
||||||
|
if retention_map.get(key) != days:
|
||||||
|
retention_map[key] = days
|
||||||
|
changed += 1
|
||||||
|
retention_store.save(retention_map)
|
||||||
|
retention = retention_store.load()
|
||||||
|
deleted = manager.apply_retention(retention, retention_store.default_days)
|
||||||
|
user = auth.current_user()
|
||||||
|
_audit("retention_update", f"entries={changed} deleted={len(deleted)}", user=user)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"logs": manager.domain_snapshot(retention, retention_store.default_days),
|
||||||
|
"retention_overrides": retention,
|
||||||
|
"retention_deleted": deleted,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@blueprint.route("/<path:log_name>", methods=["DELETE"])
|
||||||
|
def delete_log(log_name: str):
|
||||||
|
error = _require_admin()
|
||||||
|
if error:
|
||||||
|
return jsonify(error[0]), error[1]
|
||||||
|
scope = (request.args.get("scope") or "file").lower()
|
||||||
|
deleted: List[str] = []
|
||||||
|
try:
|
||||||
|
if scope == "family":
|
||||||
|
deleted = manager.delete_family(log_name)
|
||||||
|
else:
|
||||||
|
deleted = [manager.delete_file(log_name)]
|
||||||
|
except FileNotFoundError:
|
||||||
|
return jsonify({"error": "not_found", "message": "Log file not found."}), 404
|
||||||
|
user = auth.current_user()
|
||||||
|
_audit("log_delete", f"scope={scope} files={','.join(deleted)}", user=user)
|
||||||
|
retention = retention_store.load()
|
||||||
|
payload = {
|
||||||
|
"status": "deleted",
|
||||||
|
"deleted": deleted,
|
||||||
|
"logs": manager.domain_snapshot(retention, retention_store.default_days),
|
||||||
|
}
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
app.register_blueprint(blueprint)
|
||||||
682
Data/Engine/web-interface/src/Admin/Log_Management.jsx
Normal file
682
Data/Engine/web-interface/src/Admin/Log_Management.jsx
Normal file
@@ -0,0 +1,682 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Stack,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
TextField,
|
||||||
|
Divider,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
ToggleButton,
|
||||||
|
Alert,
|
||||||
|
InputAdornment,
|
||||||
|
} from "@mui/material";
|
||||||
|
import {
|
||||||
|
ReceiptLong as LogsIcon,
|
||||||
|
Refresh as RefreshIcon,
|
||||||
|
DeleteOutline as DeleteIcon,
|
||||||
|
Save as SaveIcon,
|
||||||
|
Visibility as VisibilityIcon,
|
||||||
|
VisibilityOff as VisibilityOffIcon,
|
||||||
|
History as HistoryIcon,
|
||||||
|
} from "@mui/icons-material";
|
||||||
|
import { AgGridReact } from "ag-grid-react";
|
||||||
|
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||||
|
import Prism from "prismjs";
|
||||||
|
import "prismjs/components/prism-bash";
|
||||||
|
import "prismjs/themes/prism-okaidia.css";
|
||||||
|
import Editor from "react-simple-code-editor";
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
|
const AURORA_SHELL = {
|
||||||
|
background:
|
||||||
|
"radial-gradient(120% 80% at 0% 0%, rgba(77, 172, 255, 0.22) 0%, rgba(4, 7, 17, 0.0) 60%)," +
|
||||||
|
"radial-gradient(100% 80% at 100% 0%, rgba(214, 130, 255, 0.25) 0%, rgba(6, 9, 20, 0.0) 62%)," +
|
||||||
|
"linear-gradient(135deg, rgba(3,6,15,1) 0%, rgba(8,12,25,0.97) 50%, rgba(13,7,20,0.95) 100%)",
|
||||||
|
panel:
|
||||||
|
"linear-gradient(135deg, rgba(10, 16, 31, 0.98) 0%, rgba(6, 10, 24, 0.95) 60%, rgba(15, 6, 26, 0.97) 100%)",
|
||||||
|
border: "rgba(148, 163, 184, 0.28)",
|
||||||
|
text: "#e2e8f0",
|
||||||
|
muted: "#94a3b8",
|
||||||
|
accent: "#7db7ff",
|
||||||
|
};
|
||||||
|
|
||||||
|
const gradientButtonSx = {
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#041125",
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundImage: "linear-gradient(135deg, #7dd3fc 0%, #c084fc 100%)",
|
||||||
|
boxShadow: "0 10px 25px rgba(124, 58, 237, 0.4)",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundImage: "linear-gradient(135deg, #8be9ff 0%, #d5a8ff 100%)",
|
||||||
|
boxShadow: "0 14px 32px rgba(124, 58, 237, 0.45)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const quartzTheme = themeQuartz.withParams({
|
||||||
|
accentColor: "#7db7ff",
|
||||||
|
backgroundColor: "#05070f",
|
||||||
|
foregroundColor: "#f8fafc",
|
||||||
|
headerBackgroundColor: "#0b1527",
|
||||||
|
browserColorScheme: "dark",
|
||||||
|
fontFamily: { googleFont: "IBM Plex Sans" },
|
||||||
|
headerFontSize: 14,
|
||||||
|
borderColor: "rgba(148,163,184,0.28)",
|
||||||
|
});
|
||||||
|
const quartzThemeClass = quartzTheme.themeName || "ag-theme-quartz";
|
||||||
|
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
|
||||||
|
const gridThemeStyle = {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
"--ag-foreground-color": "#f1f5f9",
|
||||||
|
"--ag-background-color": "#050b16",
|
||||||
|
"--ag-header-background-color": "#0f1a2e",
|
||||||
|
"--ag-odd-row-background-color": "rgba(255,255,255,0.02)",
|
||||||
|
"--ag-row-hover-color": "rgba(109, 196, 255, 0.12)",
|
||||||
|
"--ag-border-color": "rgba(148,163,184,0.28)",
|
||||||
|
"--ag-font-family": gridFontFamily,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
|
const idx = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
const value = bytes / Math.pow(1024, idx);
|
||||||
|
return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts) {
|
||||||
|
if (!ts) return "n/a";
|
||||||
|
try {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogManagement({ isAdmin = false }) {
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [defaultRetention, setDefaultRetention] = useState(30);
|
||||||
|
const [selectedDomain, setSelectedDomain] = useState(null);
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
const [retentionDraft, setRetentionDraft] = useState("");
|
||||||
|
const [listLoading, setListLoading] = useState(false);
|
||||||
|
const [entryLoading, setEntryLoading] = useState(false);
|
||||||
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [entriesMeta, setEntriesMeta] = useState(null);
|
||||||
|
const [gridMode, setGridMode] = useState("structured");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [actionMessage, setActionMessage] = useState(null);
|
||||||
|
const [quickFilter, setQuickFilter] = useState("");
|
||||||
|
const gridRef = useRef(null);
|
||||||
|
|
||||||
|
const logMap = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
logs.forEach((log) => map.set(log.file, log));
|
||||||
|
return map;
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const rawContent = useMemo(() => entries.map((entry) => entry.raw || "").join("\n"), [entries]);
|
||||||
|
|
||||||
|
const columnDefs = useMemo(
|
||||||
|
() => [
|
||||||
|
{ headerName: "Timestamp", field: "timestamp", minWidth: 200 },
|
||||||
|
{ headerName: "Level", field: "level", width: 110, valueFormatter: (p) => (p.value || "").toUpperCase() },
|
||||||
|
{ headerName: "Scope", field: "scope", minWidth: 120 },
|
||||||
|
{ headerName: "Service", field: "service", minWidth: 160 },
|
||||||
|
{ headerName: "Message", field: "message", flex: 1, minWidth: 300 },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultColDef = useMemo(
|
||||||
|
() => ({
|
||||||
|
sortable: true,
|
||||||
|
filter: true,
|
||||||
|
resizable: true,
|
||||||
|
wrapText: false,
|
||||||
|
cellStyle: { fontFamily: gridFontFamily, fontSize: "0.9rem", color: "#e2e8f0" },
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyQuickFilter = useCallback(
|
||||||
|
(value) => {
|
||||||
|
setQuickFilter(value);
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (api) api.setGridOption("quickFilterText", value);
|
||||||
|
},
|
||||||
|
[setQuickFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchLogs = useCallback(async () => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
setListLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setActionMessage(null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/server/logs", { credentials: "include" });
|
||||||
|
if (!resp.ok) throw new Error(`Failed to load logs (HTTP ${resp.status})`);
|
||||||
|
const data = await resp.json();
|
||||||
|
setLogs(Array.isArray(data?.logs) ? data.logs : []);
|
||||||
|
setDefaultRetention(Number.isFinite(data?.default_retention_days) ? data.default_retention_days : 30);
|
||||||
|
if (Array.isArray(data?.retention_deleted) && data.retention_deleted.length > 0) {
|
||||||
|
setActionMessage(`Removed ${data.retention_deleted.length} expired log files automatically.`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setListLoading(false);
|
||||||
|
}
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
const fetchEntries = useCallback(
|
||||||
|
async (file) => {
|
||||||
|
if (!file) return;
|
||||||
|
setEntryLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/server/logs/${encodeURIComponent(file)}/entries?limit=1000`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`Failed to load log entries (HTTP ${resp.status})`);
|
||||||
|
const data = await resp.json();
|
||||||
|
setEntries(Array.isArray(data?.entries) ? data.entries : []);
|
||||||
|
setEntriesMeta(data);
|
||||||
|
applyQuickFilter("");
|
||||||
|
} catch (err) {
|
||||||
|
setEntries([]);
|
||||||
|
setEntriesMeta(null);
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setEntryLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[applyQuickFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) fetchLogs();
|
||||||
|
}, [fetchLogs, isAdmin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!logs.length) {
|
||||||
|
setSelectedDomain(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedDomain((prev) => (prev && logMap.has(prev) ? prev : logs[0].file));
|
||||||
|
}, [logs, logMap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDomain) {
|
||||||
|
setSelectedFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const domain = logMap.get(selectedDomain);
|
||||||
|
if (!domain) return;
|
||||||
|
const firstVersion = domain.versions?.[0]?.file || domain.file;
|
||||||
|
setSelectedFile((prev) => {
|
||||||
|
if (domain.versions?.some((v) => v.file === prev)) return prev;
|
||||||
|
return firstVersion;
|
||||||
|
});
|
||||||
|
setRetentionDraft(String(domain.retention_days ?? defaultRetention));
|
||||||
|
}, [defaultRetention, logMap, selectedDomain]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFile) fetchEntries(selectedFile);
|
||||||
|
}, [fetchEntries, selectedFile]);
|
||||||
|
|
||||||
|
const selectedDomainData = selectedDomain ? logMap.get(selectedDomain) : null;
|
||||||
|
|
||||||
|
const handleRetentionSave = useCallback(async () => {
|
||||||
|
if (!selectedDomainData) return;
|
||||||
|
const trimmed = String(retentionDraft || "").trim();
|
||||||
|
const payloadValue = trimmed ? parseInt(trimmed, 10) : null;
|
||||||
|
setActionMessage(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/server/logs/retention", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
retention: {
|
||||||
|
[selectedDomainData.file]: Number.isFinite(payloadValue) ? payloadValue : null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`Failed to update retention (HTTP ${resp.status})`);
|
||||||
|
const data = await resp.json();
|
||||||
|
setLogs(Array.isArray(data?.logs) ? data.logs : logs);
|
||||||
|
if (Array.isArray(data?.retention_deleted) && data.retention_deleted.length > 0) {
|
||||||
|
setActionMessage(`Retention applied. Removed ${data.retention_deleted.length} expired files.`);
|
||||||
|
} else {
|
||||||
|
setActionMessage("Retention policy saved.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
}, [logs, retentionDraft, selectedDomainData]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (scope) => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
const confirmMessage =
|
||||||
|
scope === "family"
|
||||||
|
? "Delete this log domain and all rotated files?"
|
||||||
|
: "Delete the currently selected log file?";
|
||||||
|
if (!window.confirm(confirmMessage)) return;
|
||||||
|
setActionMessage(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const target = scope === "family" ? selectedDomainData?.file || selectedFile : selectedFile;
|
||||||
|
const resp = await fetch(`/api/server/logs/${encodeURIComponent(target)}?scope=${scope}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`Failed to delete log (HTTP ${resp.status})`);
|
||||||
|
const data = await resp.json();
|
||||||
|
setLogs(Array.isArray(data?.logs) ? data.logs : logs);
|
||||||
|
setEntries([]);
|
||||||
|
setEntriesMeta(null);
|
||||||
|
setActionMessage("Log files deleted.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[logs, selectedDomainData, selectedFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const disableRetentionSave =
|
||||||
|
!selectedDomainData ||
|
||||||
|
(String(selectedDomainData.retention_days ?? defaultRetention) === retentionDraft.trim() &&
|
||||||
|
retentionDraft.trim() !== "");
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ m: 3, p: 4, bgcolor: AURORA_SHELL.panel }}>
|
||||||
|
<Typography color="error">Administrator permissions are required to view log management.</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
p: 0,
|
||||||
|
minHeight: "100%",
|
||||||
|
backgroundImage: AURORA_SHELL.background,
|
||||||
|
borderRadius: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 3, borderBottom: `1px solid ${AURORA_SHELL.border}` }}>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: "12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: "rgba(125,183,255,0.15)",
|
||||||
|
color: AURORA_SHELL.accent,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogsIcon fontSize="small" />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ color: AURORA_SHELL.text, fontWeight: 600 }}>
|
||||||
|
Log Management
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.muted }}>
|
||||||
|
Analyze engine logs and adjust log retention periods for different engine services.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
fetchLogs();
|
||||||
|
if (selectedFile) fetchEntries(selectedFile);
|
||||||
|
}}
|
||||||
|
sx={gradientButtonSx}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Box sx={{ px: 3, pt: 2 }}>
|
||||||
|
<Alert severity="error">{error}</Alert>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{actionMessage && (
|
||||||
|
<Box sx={{ px: 3, pt: 2 }}>
|
||||||
|
<Alert severity="success">{actionMessage}</Alert>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", flexGrow: 1, minHeight: 0 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 360,
|
||||||
|
p: 3,
|
||||||
|
borderRight: `1px solid ${AURORA_SHELL.border}`,
|
||||||
|
bgcolor: "rgba(3,7,18,0.7)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl fullWidth variant="outlined" size="small">
|
||||||
|
<InputLabel id="log-select-label">Log Domain</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="log-select-label"
|
||||||
|
label="Log Domain"
|
||||||
|
value={selectedDomain || ""}
|
||||||
|
onChange={(e) => setSelectedDomain(e.target.value)}
|
||||||
|
>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<MenuItem key={log.file} value={log.file}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ width: "100%" }}>
|
||||||
|
<Typography sx={{ flexGrow: 1 }}>{log.display_name || log.file}</Typography>
|
||||||
|
<Chip
|
||||||
|
label={formatBytes(log.size_bytes || 0)}
|
||||||
|
size="small"
|
||||||
|
sx={{ bgcolor: "rgba(125,183,255,0.15)", color: AURORA_SHELL.text }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{selectedDomainData && (
|
||||||
|
<>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel id="version-select-label">Log File</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="version-select-label"
|
||||||
|
label="Log File"
|
||||||
|
value={selectedFile || ""}
|
||||||
|
onChange={(e) => setSelectedFile(e.target.value)}
|
||||||
|
>
|
||||||
|
{(selectedDomainData.versions || [{ file: selectedDomainData.file, label: "Active" }]).map(
|
||||||
|
(ver) => (
|
||||||
|
<MenuItem key={ver.file} value={ver.file}>
|
||||||
|
{ver.label}{" "}
|
||||||
|
<Typography component="span" sx={{ ml: 1, color: AURORA_SHELL.muted, fontSize: "0.8rem" }}>
|
||||||
|
{formatTimestamp(ver.modified)} · {formatBytes(ver.size_bytes || 0)}
|
||||||
|
</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
bgcolor: "rgba(255,255,255,0.03)",
|
||||||
|
borderRadius: 2,
|
||||||
|
border: `1px solid ${AURORA_SHELL.border}`,
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.muted, fontSize: "0.85rem", mb: 1 }}>Overview</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Stack direction="row" justifyContent="space-between">
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.muted }}>Size</Typography>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.text, fontWeight: 600 }}>
|
||||||
|
{formatBytes(selectedDomainData.size_bytes || 0)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" justifyContent="space-between">
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.muted }}>Last Updated</Typography>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.text }}>{formatTimestamp(selectedDomainData.modified)}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" justifyContent="space-between">
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.muted }}>Rotations</Typography>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.text }}>{selectedDomainData.rotation_count || 0}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" justifyContent="space-between">
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.muted }}>Retention</Typography>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.text }}>
|
||||||
|
{(selectedDomainData.retention_days ?? defaultRetention) || defaultRetention} days
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider light sx={{ borderColor: AURORA_SHELL.border }} />
|
||||||
|
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography sx={{ color: AURORA_SHELL.muted, fontSize: "0.85rem" }}>Retention Policy</Typography>
|
||||||
|
<TextField
|
||||||
|
label="Retention Days"
|
||||||
|
size="small"
|
||||||
|
value={retentionDraft}
|
||||||
|
onChange={(e) => setRetentionDraft(e.target.value)}
|
||||||
|
helperText={`Leave blank to inherit default (${defaultRetention} days).`}
|
||||||
|
InputProps={{ inputProps: { min: 0 } }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
onClick={handleRetentionSave}
|
||||||
|
disabled={!selectedDomainData || disableRetentionSave}
|
||||||
|
sx={{
|
||||||
|
...gradientButtonSx,
|
||||||
|
opacity: !selectedDomainData || disableRetentionSave ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save Retention
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider light sx={{ borderColor: AURORA_SHELL.border }} />
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
onClick={() => handleDelete("file")}
|
||||||
|
sx={{
|
||||||
|
color: "#f97316",
|
||||||
|
borderColor: "rgba(249,115,22,0.6)",
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete File
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<HistoryIcon />}
|
||||||
|
onClick={() => handleDelete("family")}
|
||||||
|
sx={{
|
||||||
|
color: "#f43f5e",
|
||||||
|
borderColor: "rgba(244,63,94,0.6)",
|
||||||
|
textTransform: "none",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Purge Domain
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listLoading && (
|
||||||
|
<Stack alignItems="center" justifyContent="center" sx={{ flexGrow: 1 }}>
|
||||||
|
<CircularProgress size={32} />
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flexGrow: 1, display: "flex", flexDirection: "column", minWidth: 0, p: 3, gap: 2 }}>
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2} alignItems="center">
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
size="small"
|
||||||
|
value={gridMode}
|
||||||
|
onChange={(_, val) => val && setGridMode(val)}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "rgba(0,0,0,0.25)",
|
||||||
|
borderRadius: 999,
|
||||||
|
p: 0.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="structured" sx={{ color: AURORA_SHELL.text, textTransform: "none" }}>
|
||||||
|
<VisibilityIcon fontSize="small" sx={{ mr: 1 }} /> Structured
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="raw" sx={{ color: AURORA_SHELL.text, textTransform: "none" }}>
|
||||||
|
<VisibilityOffIcon fontSize="small" sx={{ mr: 1 }} /> Raw
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
<TextField
|
||||||
|
placeholder="Quick filter"
|
||||||
|
size="small"
|
||||||
|
value={quickFilter}
|
||||||
|
onChange={(e) => applyQuickFilter(e.target.value)}
|
||||||
|
sx={{
|
||||||
|
minWidth: 220,
|
||||||
|
flexGrow: 1,
|
||||||
|
"& .MuiOutlinedInput-root": {
|
||||||
|
bgcolor: "rgba(255,255,255,0.05)",
|
||||||
|
borderRadius: 999,
|
||||||
|
color: AURORA_SHELL.text,
|
||||||
|
},
|
||||||
|
"& .MuiInputBase-input": {
|
||||||
|
color: AURORA_SHELL.text,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<LogsIcon fontSize="small" sx={{ color: AURORA_SHELL.muted }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Reload entries">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => selectedFile && fetchEntries(selectedFile)}
|
||||||
|
disabled={!selectedFile || entryLoading}
|
||||||
|
sx={{ color: AURORA_SHELL.accent }}
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{gridMode === "structured" ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: `1px solid ${AURORA_SHELL.border}`,
|
||||||
|
bgcolor: "rgba(5,7,15,0.85)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entryLoading ? (
|
||||||
|
<Stack alignItems="center" justifyContent="center" sx={{ height: "100%" }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<div className={quartzThemeClass} style={gridThemeStyle}>
|
||||||
|
<AgGridReact
|
||||||
|
ref={gridRef}
|
||||||
|
rowData={entries}
|
||||||
|
columnDefs={columnDefs}
|
||||||
|
defaultColDef={defaultColDef}
|
||||||
|
rowHeight={42}
|
||||||
|
animateRows
|
||||||
|
pagination
|
||||||
|
paginationPageSize={50}
|
||||||
|
suppressCellFocus
|
||||||
|
overlayNoRowsTemplate="<span style='color:#94a3b8'>No log entries to display.</span>"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: `1px solid ${AURORA_SHELL.border}`,
|
||||||
|
bgcolor: "#050d1f",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entryLoading ? (
|
||||||
|
<Stack alignItems="center" justifyContent="center" sx={{ height: "100%" }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ height: "100%", overflow: "auto" }}>
|
||||||
|
<Editor
|
||||||
|
value={rawContent}
|
||||||
|
onValueChange={() => {}}
|
||||||
|
highlight={(code) => Prism.highlight(code, Prism.languages.bash, "bash")}
|
||||||
|
padding={16}
|
||||||
|
textareaId="raw-log-viewer"
|
||||||
|
textareaProps={{ readOnly: true }}
|
||||||
|
style={{
|
||||||
|
fontFamily:
|
||||||
|
'"IBM Plex Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
minHeight: "100%",
|
||||||
|
height: "100%",
|
||||||
|
color: "#e2e8f0",
|
||||||
|
background: "transparent",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entriesMeta && (
|
||||||
|
<Typography sx={{ fontSize: "0.85rem", color: AURORA_SHELL.muted }}>
|
||||||
|
Showing {entriesMeta.returned_lines} of {entriesMeta.total_lines} lines from {entriesMeta.file}
|
||||||
|
{entriesMeta.truncated ? " (truncated to the most recent lines)" : ""}.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ import UserManagement from "./Access_Management/Users.jsx";
|
|||||||
import GithubAPIToken from "./Access_Management/Github_API_Token.jsx";
|
import GithubAPIToken from "./Access_Management/Github_API_Token.jsx";
|
||||||
import ServerInfo from "./Admin/Server_Info.jsx";
|
import ServerInfo from "./Admin/Server_Info.jsx";
|
||||||
import PageTemplate from "./Admin/Page_Template.jsx";
|
import PageTemplate from "./Admin/Page_Template.jsx";
|
||||||
|
import LogManagement from "./Admin/Log_Management.jsx";
|
||||||
import EnrollmentCodes from "./Devices/Enrollment_Codes.jsx";
|
import EnrollmentCodes from "./Devices/Enrollment_Codes.jsx";
|
||||||
import DeviceApprovals from "./Devices/Device_Approvals.jsx";
|
import DeviceApprovals from "./Devices/Device_Approvals.jsx";
|
||||||
|
|
||||||
@@ -462,6 +463,10 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
items.push({ label: "Admin Settings" });
|
items.push({ label: "Admin Settings" });
|
||||||
items.push({ label: "Server Info", page: "server_info" });
|
items.push({ label: "Server Info", page: "server_info" });
|
||||||
break;
|
break;
|
||||||
|
case "log_management":
|
||||||
|
items.push({ label: "Admin Settings" });
|
||||||
|
items.push({ label: "Log Management", page: "log_management" });
|
||||||
|
break;
|
||||||
case "page_template":
|
case "page_template":
|
||||||
items.push({ label: "Developer Tools" });
|
items.push({ label: "Developer Tools" });
|
||||||
items.push({ label: "Page Template", page: "page_template" });
|
items.push({ label: "Page Template", page: "page_template" });
|
||||||
@@ -1004,6 +1009,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
|| currentPage === 'access_credentials'
|
|| currentPage === 'access_credentials'
|
||||||
|| currentPage === 'access_github_token'
|
|| currentPage === 'access_github_token'
|
||||||
|| currentPage === 'access_users'
|
|| currentPage === 'access_users'
|
||||||
|
|| currentPage === 'log_management'
|
||||||
|| currentPage === 'ssh_devices'
|
|| currentPage === 'ssh_devices'
|
||||||
|| currentPage === 'winrm_devices'
|
|| currentPage === 'winrm_devices'
|
||||||
|| currentPage === 'agent_devices'
|
|| currentPage === 'agent_devices'
|
||||||
@@ -1132,6 +1138,8 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
|
|
||||||
case "server_info":
|
case "server_info":
|
||||||
return <ServerInfo isAdmin={isAdmin} />;
|
return <ServerInfo isAdmin={isAdmin} />;
|
||||||
|
case "log_management":
|
||||||
|
return <LogManagement isAdmin={isAdmin} />;
|
||||||
|
|
||||||
case "page_template":
|
case "page_template":
|
||||||
return <PageTemplate isAdmin={isAdmin} />;
|
return <PageTemplate isAdmin={isAdmin} />;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
Key as KeyIcon,
|
Key as KeyIcon,
|
||||||
Dashboard as PageTemplateIcon,
|
Dashboard as PageTemplateIcon,
|
||||||
AdminPanelSettings as AdminPanelSettingsIcon,
|
AdminPanelSettings as AdminPanelSettingsIcon,
|
||||||
|
ReceiptLong as LogsIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
@@ -69,7 +70,7 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
"access_users",
|
"access_users",
|
||||||
"access_github_token",
|
"access_github_token",
|
||||||
].includes(currentPage),
|
].includes(currentPage),
|
||||||
admin: ["server_info", "page_template"].includes(currentPage),
|
admin: ["server_info", "log_management", "page_template"].includes(currentPage),
|
||||||
}),
|
}),
|
||||||
[currentPage]
|
[currentPage]
|
||||||
);
|
);
|
||||||
@@ -286,6 +287,11 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
label="Server Info"
|
label="Server Info"
|
||||||
pageKey="server_info"
|
pageKey="server_info"
|
||||||
/>
|
/>
|
||||||
|
<NavItem
|
||||||
|
icon={<LogsIcon fontSize="small" />}
|
||||||
|
label="Log Management"
|
||||||
|
pageKey="log_management"
|
||||||
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
icon={<PageTemplateIcon fontSize="small" />}
|
icon={<PageTemplateIcon fontSize="small" />}
|
||||||
label="Page Template"
|
label="Page Template"
|
||||||
|
|||||||
Reference in New Issue
Block a user