diff --git a/.github/workflows/migration-status.yml b/.github/workflows/migration-status.yml new file mode 100644 index 0000000..6fd4cb1 --- /dev/null +++ b/.github/workflows/migration-status.yml @@ -0,0 +1,47 @@ +name: Update Migration Status Dashboard + +on: + schedule: + # Run daily at 00:00 UTC + - cron: "0 0 * * *" + workflow_dispatch: + # Allow nethcti-server and nethcti-middleware to trigger this workflow + repository_dispatch: + types: [migration-status-update] + +permissions: + contents: write + +jobs: + generate: + name: Generate migration data + runs-on: ubuntu-latest + steps: + - name: Checkout nethvoice-docs + uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Use the workflow token for the authenticated push at the end + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Generate migration-data.json + run: | + python3 scripts/extract-migration-status.py + + - name: Commit and push updated data + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add static/migration-data.json + # Only commit and push if the file changed + if git diff --staged --quiet; then + echo "No changes to migration data, skipping commit." + else + git commit -m "chore: update migration status data" + git push + fi diff --git a/docs/tutorial/api/cti.md b/docs/tutorial/api/cti.md index b671b31..c555c02 100644 --- a/docs/tutorial/api/cti.md +++ b/docs/tutorial/api/cti.md @@ -9,7 +9,7 @@ The CTI API provides programmatic access to the NethVoice CTI (Computer Telephon Legacy methods are also documented for reference, but migrating to the new methods is strongly recommended. New features and improvements are only available in the new API. -Full API specification is available at: [NethCTI Server full reference](https://documenter.getpostman.com/view/15699632/TzRRC88p#41f9b8cc-bea8-4917-a293-84eaedcaed08) +Full API specification is available at: [NethCTI Server full reference](https://documenter.getpostman.com/view/15699632/TzRRC88p#41f9b8cc-bea8-4917-a293-84eaedcaed08) · [NethCTI Middleware reference](https://bump.sh/nethesis/doc/nethcti-middleware/) --- @@ -291,6 +291,8 @@ const socket = io('https://nethcti.example.com', { ## Migration Guide: Legacy to New Method {#migration-guide-legacy-to-new-method} +For a full overview of which endpoints have already been migrated and which are still proxied to the legacy server, see the [API Migration Status dashboard](/migration-status). + To migrate from the legacy authentication to the new JWT-based method: 1. **Replace login endpoint**: Change from `/webrest/authentication/login` to `/api/login` diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 1687ec9..27e42bc 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -204,6 +204,23 @@ const config: Config = { }, ], }, + { + title: 'Developers', + items: [ + { + label: 'Middleware API reference', + href: 'https://bump.sh/nethesis/doc/nethcti-middleware/', + }, + { + label: 'CTI Server API reference', + href: 'https://documenter.getpostman.com/view/15699632/TzRRC88p', + }, + { + label: 'API Migration Status', + to: '/migration-status', + }, + ], + }, { title: 'More', items: [ diff --git a/scripts/README.md b/scripts/README.md index ca2923b..0c9c60f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,7 +2,76 @@ This directory contains utility scripts for managing the NethVoice documentation. -## Import Scripts +## Migration Status Dashboard + +### Generate migration-data.json + +The script fetches `nethcti-server` and `nethcti-middleware` from GitHub (shallow clone) +and produces `static/migration-data.json`, read by the migration status dashboard page. + +Default usage — always fetches the production reference branches (`ns8` for +`nethcti-server`, `main` for `nethcti-middleware`): + +> **Requires Python 3.10 or later.** + +```bash +# run from the nethvoice-docs repo root +python3 scripts/extract-migration-status.py +``` + +To test against a different branch before merging: + +```bash +python3 scripts/extract-migration-status.py \ + --server-branch my-feature-branch \ + --middleware-branch my-feature-branch +``` + +Only one of the two flags is needed if you want to override a single branch: + +```bash +python3 scripts/extract-migration-status.py --server-branch my-fix +``` + +> **Note:** The script always clones directly from GitHub remote — it never reads +> local repository files. The branches it clones must therefore already be pushed to +> `origin`. There is nothing to commit or stash locally before running the script. + +To force regeneration even when endpoint data has not changed (e.g. to update commit +SHAs or timestamps for a new deployment): + +```bash +python3 scripts/extract-migration-status.py --force +``` + +Output is always written to `static/migration-data.json`. If the generated data is +identical to the existing file (excluding the `generated_at` timestamp and the `sources` +section which contains commit SHAs), the file is left unchanged so that CI does not +produce spurious commits. This means the commit SHAs shown in the dashboard reflect the +last run that actually changed endpoint data, not necessarily the latest commit. + +### Manual endpoint mappings + +When a legacy path is replaced by a new path with a different name in the middleware, +add `@migration-replaces` annotations in `nethcti-middleware/main.go` directly above +the route definition: + +```go +// @migration-replaces: POST /authentication/old_endpoint +// @migration-note: Optional explanation of the change. +api.POST("/new/endpoint", methods.Handler) +``` + +- `@migration-replaces` must include the HTTP method and the **legacy** path. +- Multiple `@migration-replaces` lines can appear before a single route (one per legacy path). +- `@migration-note` is optional and accepts free-form text. +- The annotation block may contain blank lines and plain `//` comments between entries, but + must not be interrupted by any other code before the route declaration. + +The extraction script reads these annotations directly from the cloned middleware source — +no separate mapping file is needed. + + ### Import a RST Document diff --git a/scripts/extract-migration-status.py b/scripts/extract-migration-status.py new file mode 100644 index 0000000..c250972 --- /dev/null +++ b/scripts/extract-migration-status.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +""" +NethVoice API Migration Status Extractor + +Parses nethcti-server (Node.js) and nethcti-middleware (Go/Gin) source code +to produce a JSON report categorising each REST endpoint by migration status. + +Output schema: + generated_at ISO-8601 timestamp + sources commit SHAs for both repos + stats summary counts and migration percentage + endpoints.server[] all legacy server endpoints with migration status + endpoints.middleware[] all middleware-native endpoints with class +""" + +import argparse +import contextlib +import json +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + + +# ─── helpers ──────────────────────────────────────────────────────────────── + +def git_sha(repo_path: str) -> str: + try: + result = subprocess.run( + ["git", "-C", repo_path, "rev-parse", "HEAD"], + capture_output=True, text=True, check=True, + ) + return result.stdout.strip() + except Exception: + return "unknown" + + +@contextlib.contextmanager +def clone_repo(repo: str, branch: str): + """Clone repo from GitHub at a specific branch into a temp dir (shallow clone).""" + tmpdir = tempfile.mkdtemp(prefix="migration-clone-") + try: + result = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", branch, + f"https://github.com/{repo}.git", tmpdir], + capture_output=True, + ) + if result.returncode != 0: + print(f"Error: could not clone '{repo}' branch '{branch}':\n{result.stderr.decode()}", file=sys.stderr) + sys.exit(1) + yield tmpdir + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +def normalize_path(path: str) -> str: + """Replace :param segments with {param} so paths can be compared.""" + return re.sub(r":([^/]+)", "{param}", path) + + +# ─── nethcti-server extraction ─────────────────────────────────────────────── + +def _extract_api_block(content: str) -> str | None: + """ + Locate the api: { ... } object inside a JS plugin file. + Uses brace counting to handle nesting correctly. + """ + match = re.search(r"\bapi\s*:\s*\{", content) + if not match: + return None + depth = 0 + for i in range(match.end() - 1, len(content)): + if content[i] == "{": + depth += 1 + elif content[i] == "}": + depth -= 1 + if depth == 0: + return content[match.start() : i + 1] + return None + + +def _extract_string_array(block: str, method: str) -> list[str]: + """Return items in the 'method': [ ... ] array inside an api block.""" + arr_match = re.search( + rf"'{re.escape(method)}'\s*:\s*\[([^\]]*)\]", block, re.DOTALL + ) + if not arr_match: + return [] + return re.findall(r"'([^']+)'", arr_match.group(1)) + + +def extract_server_endpoints(server_path: str) -> list[dict]: + """Walk plugins_rest/*.js files and return all declared REST endpoints.""" + plugins_dir = Path(server_path) / "root/usr/lib/node/nethcti-server/plugins" + if not plugins_dir.exists(): + print(f"[WARN] plugins dir not found: {plugins_dir}", file=sys.stderr) + return [] + + endpoints = [] + method_map = { + "get": "GET", + "post": "POST", + "put": "PUT", + "del": "DELETE", + "head": "HEAD", + } + + for js_file in sorted(plugins_dir.rglob("plugins_rest/*.js")): + content = js_file.read_text(encoding="utf-8", errors="replace") + block = _extract_api_block(content) + if not block: + continue + + root_match = re.search(r"'root'\s*:\s*'([^']+)'", block) + if not root_match: + continue + root = root_match.group(1) + + # Relative plugin name for traceability (e.g. com_user_rest) + plugin = js_file.parts[-3] + + for js_method, http_method in method_map.items(): + for sub in _extract_string_array(block, js_method): + endpoints.append( + { + "method": http_method, + "path": f"/{root}/{sub}", + "plugin": plugin, + } + ) + + return endpoints + + +# ─── nethcti-middleware extraction ─────────────────────────────────────────── + +_ROUTE_RE = re.compile( + r'\b(?:router|api)\.(GET|POST|PUT|DELETE|HEAD|PATCH)\s*\(\s*"([^"]+)"' +) + + +def extract_middleware_endpoints(middleware_path: str) -> list[dict]: + """Parse main.go and return all natively declared REST endpoints.""" + main_go = Path(middleware_path) / "main.go" + if not main_go.exists(): + print(f"[WARN] main.go not found: {main_go}", file=sys.stderr) + return [] + + endpoints = [] + in_legacy_compat = False + prev_was_blank = False + + for line in main_go.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + + # Track the LEGACY COMPATIBILITY comment block + if "LEGACY COMPATIBILITY" in stripped: + in_legacy_compat = True + prev_was_blank = False + continue + # Handle content inside the compat block + if in_legacy_compat: + if stripped == "": + prev_was_blank = True + continue + if stripped.startswith("//"): + # A comment after a blank line means a new section — end the block + if prev_was_blank: + in_legacy_compat = False + prev_was_blank = False + continue + # Continuation comment (same block, no blank before), keep going + prev_was_blank = False + continue + prev_was_blank = False + # A non-comment, non-blank, non-route line ends the compat block + if in_legacy_compat and not _ROUTE_RE.search(stripped): + in_legacy_compat = False + + m = _ROUTE_RE.search(stripped) + if not m: + continue + + http_method = m.group(1) + path = m.group(2) + + # Classify the endpoint — skip admin and websocket (not REST migration targets) + if path.startswith("/admin/") or path.startswith("/ws"): + prev_was_blank = False + continue + elif in_legacy_compat: + cls = "deprecated" + else: + cls = "native" + + endpoints.append({"method": http_method, "path": path, "class": cls}) + + return endpoints + + +# ─── migration annotation parsing ─────────────────────────────────────────── + +_REPLACES_RE = re.compile( + r"^//\s*@migration-replaces:\s+(GET|POST|PUT|DELETE|PATCH|HEAD)\s+(\S+)\s*$" +) +_NOTE_RE = re.compile(r"^//\s*@migration-note:\s+(.+)$") + + +def parse_migration_annotations(middleware_path: str) -> list[dict]: + """ + Parse @migration-replaces / @migration-note annotations from main.go. + + Each annotation block must appear on consecutive lines (blank lines and + plain comments allowed) immediately before a route definition. + + Returns a list of mappings with the same schema as the old migration-map.json: + server_endpoints list of {method, path} + middleware_endpoint {method, path} + notes optional string + """ + main_go = Path(middleware_path) / "main.go" + if not main_go.exists(): + return [] + + mappings: list[dict] = [] + pending_replaces: list[dict] = [] + pending_note: str = "" + + for line in main_go.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + + m = _REPLACES_RE.match(stripped) + if m: + pending_replaces.append({"method": m.group(1), "path": m.group(2)}) + continue + + n = _NOTE_RE.match(stripped) + if n: + pending_note = n.group(1) + continue + + # Blank lines and plain comments don't interrupt the block + if stripped == "" or stripped.startswith("//"): + continue + + r = _ROUTE_RE.search(stripped) + if r and pending_replaces: + mappings.append({ + "middleware_endpoint": {"method": r.group(1), "path": r.group(2)}, + "server_endpoints": pending_replaces, + "notes": pending_note, + }) + + if pending_replaces and not r: + print( + f"[WARN] @migration-replaces annotation not followed by a route: {stripped!r}", + file=sys.stderr, + ) + + # Any non-blank, non-comment line resets the pending block + pending_replaces = [] + pending_note = "" + + if pending_replaces: + print("[WARN] @migration-replaces annotation at EOF was never associated with a route.", file=sys.stderr) + + return mappings + + +# ─── categorisation ───────────────────────────────────────────────────────── + +def categorise( + server_eps: list[dict], + middleware_eps: list[dict], + manual_maps: list[dict], +) -> tuple[list[dict], list[dict]]: + """ + Enrich server and middleware endpoint lists with migration status. + + Server endpoint statuses: + migrated exact path match found in middleware (after normalisation) + manually-mapped explicitly declared via @migration-replaces annotations + legacy-only no native middleware counterpart + + Middleware endpoint classes stay as set by the extractor (native, + deprecated). Manually-mapped ones get an extra + 'migrated_from' field. + """ + # Index middleware endpoints by normalised path for fast lookup + mw_index: dict[tuple, dict] = {} + for ep in middleware_eps: + key = (ep["method"], normalize_path(ep["path"])) + mw_index[key] = ep + + # Index server endpoints by normalised path + srv_index: dict[tuple, dict] = {} + for ep in server_eps: + key = (ep["method"], normalize_path(ep["path"])) + srv_index[key] = ep + + # Apply manual mappings first + manually_mapped_srv: set[tuple] = set() + manually_mapped_mw: set[tuple] = set() + # Build lookup: (method, normalized_server_path) → new middleware path + srv_to_replacement: dict[tuple, str] = {} + for mapping in manual_maps: + mw_ep = mapping.get("middleware_endpoint", {}) + mw_key = (mw_ep.get("method", ""), normalize_path(mw_ep.get("path", ""))) + notes = mapping.get("notes", "") + for srv_ep in mapping.get("server_endpoints", []): + srv_key = (srv_ep.get("method", ""), normalize_path(srv_ep.get("path", ""))) + manually_mapped_srv.add(srv_key) + srv_to_replacement[srv_key] = mw_ep.get("path", "") + # Annotate the matching server endpoint + if srv_key in srv_index: + srv_index[srv_key]["status"] = "manually-mapped" + srv_index[srv_key]["mapped_to"] = mw_ep.get("path", "") + srv_index[srv_key]["notes"] = notes + # Annotate the matching middleware endpoint + if mw_key in mw_index: + mw_index[mw_key]["migrated_from"] = [ + ep.get("path", "") for ep in mapping.get("server_endpoints", []) + ] + mw_index[mw_key]["manually_mapped"] = True + manually_mapped_mw.add(mw_key) + + # Auto-match remaining server endpoints by normalised path + annotated_server: list[dict] = [] + for ep in server_eps: + key = (ep["method"], normalize_path(ep["path"])) + if "status" in ep: + # Already handled by manual mapping + annotated_server.append(ep) + continue + if key in mw_index: + ep["status"] = "migrated" + ep["migrated_to"] = mw_index[key]["path"] + else: + ep["status"] = "legacy-only" + annotated_server.append(ep) + + # Auto-annotate middleware endpoints that auto-matched a server endpoint + annotated_middleware: list[dict] = [] + for ep in middleware_eps: + key = (ep["method"], normalize_path(ep["path"])) + if ep.get("class") == "deprecated" and key in srv_to_replacement: + ep["replaced_by"] = srv_to_replacement[key] + ep["manually_mapped"] = True + elif key not in manually_mapped_mw and key in srv_index: + ep["migrated_from"] = [srv_index[key]["path"]] + annotated_middleware.append(ep) + + return annotated_server, annotated_middleware + + +# ─── stats ────────────────────────────────────────────────────────────────── + +def compute_stats(server_eps: list[dict], middleware_eps: list[dict]) -> dict: + total_legacy = len(server_eps) + migrated = sum( + 1 for ep in server_eps if ep.get("status") in ("migrated", "manually-mapped") + ) + legacy_only = total_legacy - migrated + + # Only count endpoints that were in the legacy server + migration_pct = round(migrated / total_legacy * 100, 1) if total_legacy else 0.0 + + mw_by_class: dict[str, int] = {} + for ep in middleware_eps: + cls = ep.get("class", "native") + mw_by_class[cls] = mw_by_class.get(cls, 0) + 1 + + new_mw = sum( + 1 for ep in middleware_eps + if not ep.get("migrated_from") and ep.get("class") not in ("deprecated",) + ) + + return { + "total_legacy": total_legacy, + "migrated": migrated, + "legacy_only": legacy_only, + "migration_percentage": migration_pct, + "middleware_by_class": mw_by_class, + "new_in_middleware": new_mw, + } + + +# ─── main ─────────────────────────────────────────────────────────────────── + +SCRIPT_DIR = Path(__file__).parent +REPO_ROOT = SCRIPT_DIR.parent +OUTPUT_PATH = REPO_ROOT / "static" / "migration-data.json" + +SERVER_REPO = "nethesis/nethcti-server" +MIDDLEWARE_REPO = "nethesis/nethcti-middleware" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Extract NethVoice API migration status") + parser.add_argument("--server-branch", default="ns8", help="nethcti-server branch (default: ns8)") + parser.add_argument("--middleware-branch", default="main", help="nethcti-middleware branch (default: main)") + parser.add_argument("--force", action="store_true", help="Always write output even if endpoint data has not changed") + args = parser.parse_args() + + with clone_repo(SERVER_REPO, args.server_branch) as server_path, \ + clone_repo(MIDDLEWARE_REPO, args.middleware_branch) as middleware_path: + + server_eps = extract_server_endpoints(server_path) + middleware_eps = extract_middleware_endpoints(middleware_path) + manual_maps = parse_migration_annotations(middleware_path) + + server_eps, middleware_eps = categorise(server_eps, middleware_eps, manual_maps) + stats = compute_stats(server_eps, middleware_eps) + + output = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "sources": { + "nethcti_server": { + "repo": SERVER_REPO, + "branch": args.server_branch, + "commit": git_sha(server_path), + }, + "nethcti_middleware": { + "repo": MIDDLEWARE_REPO, + "branch": args.middleware_branch, + "commit": git_sha(middleware_path), + }, + }, + "stats": stats, + "endpoints": { + "server": server_eps, + "middleware": middleware_eps, + }, + } + + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + + def endpoint_data(d: dict) -> dict: + """Return only the parts that matter for change detection (not timestamps or commit SHAs).""" + return {k: v for k, v in d.items() if k not in ("generated_at", "sources")} + + existing = {} + if OUTPUT_PATH.exists(): + try: + existing = json.loads(OUTPUT_PATH.read_text()) + except Exception: + pass + + if not args.force and endpoint_data(output) == endpoint_data(existing): + print("No changes to migration data, skipping write.", file=sys.stderr) + else: + OUTPUT_PATH.write_text(json.dumps(output, indent=2, ensure_ascii=False)) + print( + f"Generated {OUTPUT_PATH}: " + f"{stats['total_legacy']} legacy endpoints, " + f"{stats['migrated']} migrated ({stats['migration_percentage']}%)", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main() diff --git a/src/pages/migration-status.tsx b/src/pages/migration-status.tsx new file mode 100644 index 0000000..4748fbe --- /dev/null +++ b/src/pages/migration-status.tsx @@ -0,0 +1,657 @@ +import type { ReactNode, CSSProperties } from "react"; +import { useEffect, useState } from "react"; +import Layout from "@theme/Layout"; +import Heading from "@theme/Heading"; +import useBaseUrl from "@docusaurus/useBaseUrl"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type ServerEndpoint = { + method: string; + path: string; + plugin: string; + status: "migrated" | "legacy-only" | "manually-mapped"; + migrated_to?: string; + mapped_to?: string; + notes?: string; +}; + +type MiddlewareEndpoint = { + method: string; + path: string; + class: "native" | "deprecated"; + migrated_from?: string[]; + replaced_by?: string; + manually_mapped?: boolean; +}; + +type MigrationData = { + generated_at: string; + sources: { + nethcti_server: { repo: string; branch: string; commit: string }; + nethcti_middleware: { repo: string; branch: string; commit: string }; + }; + stats: { + total_legacy: number; + migrated: number; + legacy_only: number; + migration_percentage: number; + middleware_by_class: Record; + new_in_middleware: number; + }; + endpoints: { + server: ServerEndpoint[]; + middleware: MiddlewareEndpoint[]; + }; +}; + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const METHOD_COLORS: Record = { + GET: "#61affe", + POST: "#49cc90", + PUT: "#fca130", + DELETE: "#f93e3e", + HEAD: "#9012fe", + PATCH: "#50e3c2", +}; + +const STATUS_BADGE: Record = { + migrated: { label: "Migrated", color: "#fff", bg: "#28a745" }, + "manually-mapped": { label: "Mapped", color: "#fff", bg: "#17a2b8" }, + "legacy-only": { label: "Legacy", color: "#fff", bg: "#dc3545" }, +}; + +const CLASS_BADGE: Record = { + native: { label: "Native", color: "#fff", bg: "#28a745" }, + deprecated: { label: "Deprecated", color: "#212529", bg: "#ffc107" }, +}; + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function MethodBadge({ method }: { method: string }) { + return ( + + {method} + + ); +} + +function Badge({ label, color, bg }: { label: string; color: string; bg: string }) { + return ( + + {label} + + ); +} + +function ProgressBar({ pct }: { pct: number }) { + return ( +
+
+
+ ); +} + +function StatCard({ + value, + label, + color, +}: { + value: string | number; + label: string; + color?: string; +}) { + return ( +
+
+ {value} +
+
+ {label} +
+
+ ); +} + +function EndpointTable({ + endpoints, + renderExtra, +}: { + endpoints: T[]; + renderExtra: (ep: T) => ReactNode; +}) { + const [search, setSearch] = useState(""); + const [methodFilter, setMethodFilter] = useState("ALL"); + + const methods = ["ALL", ...Array.from(new Set(endpoints.map((e) => e.method))).sort()]; + const filtered = endpoints.filter((ep) => { + const matchMethod = methodFilter === "ALL" || ep.method === methodFilter; + const q = search.toLowerCase(); + const matchSearch = !q || ep.path.toLowerCase().includes(q) || ep.method.toLowerCase().includes(q); + return matchMethod && matchSearch; + }); + + return ( +
+ {/* Filters */} +
+ setSearch(e.target.value)} + style={{ + flex: 1, + minWidth: 200, + padding: "6px 10px", + borderRadius: 6, + border: "1px solid var(--ifm-color-emphasis-300)", + background: "var(--ifm-background-color)", + color: "var(--ifm-font-color-base)", + fontSize: "0.9rem", + }} + /> +
+ {methods.map((m) => ( + + ))} +
+
+ + {/* Count */} +
+ {filtered.length} of {endpoints.length} endpoints +
+ + {/* Table */} +
+ + + + + + + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((ep) => ( + + + + + + )) + )} + +
MethodPathDetails
+ No endpoints match your filter. +
+ + + {ep.path} + {renderExtra(ep)}
+
+
+ ); +} + +const thStyle: CSSProperties = { + textAlign: "left", + padding: "8px 12px", + color: "var(--ifm-color-emphasis-600)", + fontWeight: 600, + fontSize: "0.8rem", + textTransform: "uppercase", + letterSpacing: "0.05em", +}; + +const tdStyle: CSSProperties = { + padding: "8px 12px", + verticalAlign: "top", +}; + +// ─── Main page ─────────────────────────────────────────────────────────────── + +type TabKey = "server" | "middleware"; + +export default function MigrationStatus(): ReactNode { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [tab, setTab] = useState("server"); + const [serverFilter, setServerFilter] = useState("ALL"); + const [middlewareFilter, setMiddlewareFilter] = useState("ALL"); + const migrationDataUrl = useBaseUrl("/migration-data.json"); + + useEffect(() => { + fetch(migrationDataUrl) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then(setData) + .catch((e) => setError(e.message)); + }, [migrationDataUrl]); + + const filteredServer = + data?.endpoints.server.filter( + (ep) => serverFilter === "ALL" || + (serverFilter === "migrated" ? (ep.status === "migrated" || ep.status === "manually-mapped") : ep.status === serverFilter) + ) ?? []; + + const filteredMiddleware = + data?.endpoints.middleware.filter( + (ep) => + (ep.class === "native" || ep.class === "deprecated") && + (middlewareFilter === "ALL" || ep.class === middlewareFilter) + ) ?? []; + + return ( + +
+ {/* Header */} + + API Migration Status + +

+ Migration progress from{" "} + nethcti-server (legacy) to{" "} + nethcti-middleware (new backend). + Endpoints not yet natively implemented in the middleware are transparently + proxied to the legacy server. +

+

+ 📖{" "} + + Migration guide + +

+ + {/* Error state */} + {error && ( +
+ ⚠ Could not load migration data +
+ {error}. Run{" "} + python3 scripts/extract-migration-status.py … to generate it. +
+ )} + + {/* Loading state */} + {!data && !error && ( +

Loading…

+ )} + + {data && ( + <> + {/* Stats cards */} +
+ + + + +
+ + {/* Progress bar */} +
+ + Migration progress + + + + {data.stats.migration_percentage}% + +
+ + {/* Source info */} +
+ + Updated: {new Date(data.generated_at).toLocaleString()} + + + nethcti-server:{" "} + + {data.sources.nethcti_server.commit.slice(0, 7)} + + + + nethcti-middleware:{" "} + + {data.sources.nethcti_middleware.commit.slice(0, 7)} + + +
+ + {/* Tabs */} +
+ {( + [ + { key: "server", label: `Legacy server (${data.endpoints.server.length})` }, + { key: "middleware", label: `Middleware native (${data.endpoints.middleware.length})` }, + ] as const + ).map(({ key, label }) => ( + + ))} +
+ + {/* Server tab */} + {tab === "server" && ( + <> + {/* Status filter pills */} +
+ {["ALL", "legacy-only", "migrated"].map((f) => { + const count = + f === "ALL" + ? data.endpoints.server.length + : f === "migrated" + ? data.endpoints.server.filter((e) => e.status === "migrated" || e.status === "manually-mapped").length + : data.endpoints.server.filter((e) => e.status === f).length; + return ( + + ); + })} +
+ + ( +
+
+ + {ep.status === "manually-mapped" && ( + Mapped + )} + + {ep.plugin.replace("com_", "").replace("_rest", "")} + +
+ {(ep.migrated_to || ep.mapped_to) && ( +
+ → {ep.migrated_to || ep.mapped_to} +
+ )} + {ep.notes && ( +
+ {ep.notes} +
+ )} +
+ )} + /> + + )} + + {/* Middleware tab */} + {tab === "middleware" && ( + <> + {/* Class filter pills */} +
+ {["ALL", "native", "deprecated"].map((f) => { + const count = + f === "ALL" + ? data.endpoints.middleware.filter((e) => e.class === "native" || e.class === "deprecated").length + : data.endpoints.middleware.filter((e) => e.class === f).length; + return ( + + ); + })} +
+ + {/* Class legend */} +
+ Fully implemented in middleware + Legacy path kept for backward compatibility +
+ + ( +
+
+ + {ep.manually_mapped && ( + Manually mapped + )} +
+ {ep.class !== "deprecated" && ep.migrated_from && ep.migrated_from.length > 0 && ( +
+ Replaces:{" "} + {ep.migrated_from.map((p, i) => ( + + {p} + {i < ep.migrated_from!.length - 1 ? ", " : ""} + + ))} +
+ )} + {ep.class === "deprecated" && ep.replaced_by && ( +
+ Replaced by: {ep.replaced_by} +
+ )} +
+ )} + /> + + )} + + )} +
+
+ ); +}