refactor: full-stack restructure with multi-tenancy, workspace management, and K8s diagnostics

- Add Workspace domain (entity, repository, service, handler, DTO)
- Add multi-tenant K8s client with tenant binding and quota management
- Add K8s diagnostics client (instance diagnostics)
- Add authorization middleware (authz package)
- Restructure frontend to feature-based architecture (features/)
- Add User Management page in configuration
- Add AccessDenied page and route guards
- Refactor shared components (form inputs, layout, UI)
- Update Tailwind config for new design system
- Add comprehensive documentation (docs/, tasks/, plans)
- Improve cluster service with better kubeconfig handling
- Add tests for crypto, config, helm client, tenant binding
This commit is contained in:
Ivan087
2026-05-12 16:15:14 +08:00
parent c5e51ed069
commit 7f238a3168
172 changed files with 15703 additions and 3162 deletions

View File

@ -0,0 +1,160 @@
#!/usr/bin/env python3
# Covers InstanceCard action layout: creates a harmless failed metadata instance
# with an invalid chart before Helm runs, opens the Instances page, verifies the
# Delete button remains inside the card and viewport, clicks it, and cleans up.
import json
import os
import time
import uuid
from dataclasses import dataclass
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urljoin
from urllib.request import Request, urlopen
from playwright.sync_api import expect, sync_playwright
RAW_BASE_URL = os.environ.get("BASE_URL", "http://localhost:18081/api/v1").rstrip("/")
BASE_URL = RAW_BASE_URL + "/"
FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:18080")
ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
ADMIN_PASS = os.environ["ADMIN_PASS"]
@dataclass
class Response:
status: int
body: str
json: Any
def parse_json(body: str) -> Any:
try:
return json.loads(body) if body else None
except json.JSONDecodeError:
return None
def request(method: str, path: str, token: str | None = None, payload: Any = None) -> Response:
data = None
headers = {"Accept": "application/json"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
try:
with urlopen(Request(urljoin(BASE_URL, path.lstrip("/")), data=data, headers=headers, method=method), timeout=40) as res:
body = res.read().decode("utf-8", errors="replace")
return Response(res.status, body, parse_json(body))
except HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return Response(exc.code, body, parse_json(body))
except URLError as exc:
raise AssertionError(f"Cannot reach {BASE_URL}: {exc}") from exc
def login_api(username: str, password: str) -> str:
resp = request("POST", "/auth/login", payload={"username": username, "password": password})
if resp.status != 200:
raise AssertionError(f"login failed: HTTP {resp.status} {resp.body[:300]}")
return str(resp.json["accessToken"])
def first_id(items: Any, name: str | None = None) -> str:
if not isinstance(items, list):
raise AssertionError(f"expected list, got {items!r}")
for item in items:
if isinstance(item, dict) and item.get("id") and (name is None or item.get("name") == name):
return str(item["id"])
raise AssertionError(f"could not find id for {name or 'first item'}")
def list_instances(cluster_id: str, token: str) -> list[dict[str, Any]]:
resp = request("GET", f"/clusters/{cluster_id}/instances", token)
if resp.status != 200 or not isinstance(resp.json, dict):
raise AssertionError(f"list instances failed: HTTP {resp.status} {resp.body[:300]}")
return [item for item in resp.json.get("instances", []) if isinstance(item, dict)]
def login_ui(page) -> None:
page.goto(FRONTEND_URL, wait_until="networkidle")
if page.locator("input[type='password']").count() == 0:
return
page.locator("input:not([type='password'])").first.fill(ADMIN_USER)
page.locator("input[type='password']").first.fill(ADMIN_PASS)
page.get_by_role("button", name="Login").last.click()
page.wait_for_url("**/home", timeout=15000)
page.wait_for_load_state("networkidle")
def main() -> int:
token = login_api(ADMIN_USER, ADMIN_PASS)
clusters = request("GET", "/clusters", token)
registries = request("GET", "/registries", token)
if clusters.status != 200 or registries.status != 200:
raise AssertionError("clusters/registries must be available")
cluster_id = first_id(clusters.json, "k3s") if any(item.get("name") == "k3s" for item in clusters.json) else first_id(clusters.json)
registry_id = first_id(registries.json)
suffix = uuid.uuid4().hex[:8]
release = f"ocdp-ui-overflow-{suffix}"
namespace = f"ocdp-ui-overflow-{suffix}"
instance_id = ""
try:
create = request(
"POST",
f"/clusters/{cluster_id}/instances",
token,
{
"name": release,
"namespace": namespace,
"registryId": registry_id,
"repository": f"charts/nonexistent-ui-overflow-{suffix}",
"tag": "0.0.0",
"valuesYaml": "replicaCount: 1\n",
},
)
if create.status not in {201, 400}:
raise AssertionError(f"expected create to succeed or fail after DB insert, got HTTP {create.status}: {create.body[:500]}")
for _ in range(20):
matches = [item for item in list_instances(cluster_id, token) if item.get("name") == release]
if matches:
instance_id = str(matches[0]["id"])
break
time.sleep(0.5)
if not instance_id:
raise AssertionError("test instance was not visible after failed chart download")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 920, "height": 760})
page.on("dialog", lambda dialog: dialog.accept())
login_ui(page)
page.get_by_role("button", name="Instances", exact=True).click()
page.wait_for_load_state("networkidle")
heading = page.get_by_role("heading", name=release, exact=True)
expect(heading).to_be_visible(timeout=15000)
card = heading.locator("xpath=ancestor::div[contains(@class, 'group')][1]")
delete_button = card.get_by_role("button", name="Delete", exact=True)
expect(delete_button).to_be_visible()
card_box = card.bounding_box()
button_box = delete_button.bounding_box()
viewport = page.viewport_size or {"width": 920, "height": 760}
assert card_box and button_box, "card and delete button must have layout boxes"
assert button_box["x"] >= card_box["x"] - 1, "Delete button overflows left edge of card"
assert button_box["x"] + button_box["width"] <= card_box["x"] + card_box["width"] + 1, "Delete button overflows right edge of card"
assert button_box["x"] + button_box["width"] <= viewport["width"], "Delete button overflows viewport"
delete_button.click()
page.wait_for_timeout(500)
browser.close()
print("PASS: instance card action layout")
return 0
finally:
if instance_id:
request("DELETE", f"/clusters/{cluster_id}/instances/{instance_id}", token)
if __name__ == "__main__":
raise SystemExit(main())