mirror of
https://github.com/Z4nzu/hackingtool
synced 2026-05-23 08:58:22 +00:00
Merge e8387f71f4 into 01a51bbca6
This commit is contained in:
commit
00870c45a0
22 changed files with 5065 additions and 0 deletions
228
web/backend/catalog.py
Normal file
228
web/backend/catalog.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent
|
||||
ROOT_DIR = BACKEND_DIR.parent.parent
|
||||
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.append(str(ROOT_DIR))
|
||||
|
||||
from config import get_tools_dir
|
||||
from core import HackingTool as ODKTool, HackingToolsCollection as ODKToolsCollection
|
||||
from hackingtool import all_tools, tool_definitions
|
||||
from os_detect import CURRENT_OS
|
||||
|
||||
|
||||
def _walk_collection(items: list, leaf_title: str = "", top_level_title: str = "") -> list[tuple[ODKTool, str, str]]:
|
||||
flattened: list[tuple[ODKTool, str, str]] = []
|
||||
for item in items:
|
||||
if isinstance(item, ODKToolsCollection):
|
||||
next_leaf = item.TITLE or leaf_title
|
||||
next_top_level = top_level_title or next_leaf
|
||||
flattened.extend(_walk_collection(item.TOOLS, next_leaf, next_top_level))
|
||||
elif isinstance(item, ODKTool):
|
||||
flattened.append((item, leaf_title or top_level_title, top_level_title or leaf_title))
|
||||
return flattened
|
||||
|
||||
|
||||
def _tool_support_reason(tool: ODKTool) -> str | None:
|
||||
if getattr(tool, "ARCHIVED", False):
|
||||
return getattr(tool, "ARCHIVED_REASON", "") or "Archived"
|
||||
supported_os = getattr(tool, "SUPPORTED_OS", ["linux", "macos"])
|
||||
if CURRENT_OS.system not in supported_os:
|
||||
supported = ", ".join(supported_os)
|
||||
return f"Unsupported on {CURRENT_OS.system}. Supported: {supported}."
|
||||
return None
|
||||
|
||||
|
||||
def _tool_local_path(tool: ODKTool) -> str | None:
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(str(get_tools_dir()))
|
||||
return tool._get_tool_dir()
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
def _option_dict(tool: ODKTool, index: int, option: tuple[str, object]) -> dict:
|
||||
label, callback = option
|
||||
callback_name = getattr(callback, "__name__", "")
|
||||
|
||||
web_supported = False
|
||||
kind = "custom"
|
||||
|
||||
if label == "Install":
|
||||
kind = "install"
|
||||
web_supported = True
|
||||
elif label == "Update":
|
||||
kind = "update"
|
||||
web_supported = True
|
||||
elif label == "Uninstall":
|
||||
kind = "uninstall"
|
||||
web_supported = tool.__class__.uninstall is ODKTool.uninstall and bool(getattr(tool, "UNINSTALL_COMMANDS", []))
|
||||
elif label == "Run":
|
||||
kind = "run"
|
||||
web_supported = tool.__class__.run is ODKTool.run and bool(getattr(tool, "RUN_COMMANDS", []))
|
||||
elif label == "Open Folder":
|
||||
kind = "open-folder"
|
||||
elif label == "Update System":
|
||||
kind = "update-system"
|
||||
web_supported = True
|
||||
elif label == "Update Hacking Tool":
|
||||
kind = "update-hackingtool"
|
||||
web_supported = True
|
||||
elif callback_name == "open":
|
||||
kind = "open"
|
||||
|
||||
return {
|
||||
"index": index,
|
||||
"label": label,
|
||||
"kind": kind,
|
||||
"webSupported": web_supported,
|
||||
}
|
||||
|
||||
|
||||
def _tool_to_dict(
|
||||
tool_id: int,
|
||||
tool: ODKTool,
|
||||
category_id: int,
|
||||
category_title: str,
|
||||
category_label: str,
|
||||
category_icon: str,
|
||||
top_level_category_title: str,
|
||||
) -> dict:
|
||||
local_path = _tool_local_path(tool)
|
||||
support_reason = _tool_support_reason(tool)
|
||||
compatible = support_reason is None
|
||||
options = [_option_dict(tool, index, option) for index, option in enumerate(tool.OPTIONS)]
|
||||
|
||||
return {
|
||||
"id": tool_id,
|
||||
"title": tool.TITLE,
|
||||
"description": getattr(tool, "DESCRIPTION", "") or "",
|
||||
"categoryId": category_id,
|
||||
"category": category_title,
|
||||
"categoryLabel": category_label,
|
||||
"categoryIcon": category_icon,
|
||||
"topLevelCategory": top_level_category_title,
|
||||
"tags": getattr(tool, "TAGS", []),
|
||||
"installed": tool.is_installed if hasattr(tool, "is_installed") else False,
|
||||
"projectUrl": getattr(tool, "PROJECT_URL", "") or "",
|
||||
"localPath": local_path,
|
||||
"supportedOs": getattr(tool, "SUPPORTED_OS", ["linux", "macos"]),
|
||||
"compatible": compatible,
|
||||
"supportReason": support_reason,
|
||||
"archived": getattr(tool, "ARCHIVED", False),
|
||||
"archivedReason": getattr(tool, "ARCHIVED_REASON", "") or "",
|
||||
"requires": {
|
||||
"root": getattr(tool, "REQUIRES_ROOT", False),
|
||||
"wifi": getattr(tool, "REQUIRES_WIFI", False),
|
||||
"go": getattr(tool, "REQUIRES_GO", False),
|
||||
"ruby": getattr(tool, "REQUIRES_RUBY", False),
|
||||
"java": getattr(tool, "REQUIRES_JAVA", False),
|
||||
"docker": getattr(tool, "REQUIRES_DOCKER", False),
|
||||
},
|
||||
"commands": {
|
||||
"install": getattr(tool, "INSTALL_COMMANDS", []),
|
||||
"uninstall": getattr(tool, "UNINSTALL_COMMANDS", []),
|
||||
"run": getattr(tool, "RUN_COMMANDS", []),
|
||||
},
|
||||
"options": options,
|
||||
"actions": {
|
||||
"canInstall": any(option["kind"] == "install" and option["webSupported"] for option in options),
|
||||
"canUpdate": any(option["kind"] == "update" and option["webSupported"] for option in options),
|
||||
"canUninstall": any(option["kind"] == "uninstall" and option["webSupported"] for option in options),
|
||||
"canRun": any(option["kind"] == "run" and option["webSupported"] for option in options),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_category_collections() -> list[dict]:
|
||||
categories = []
|
||||
for index, ((full_title, icon, menu_label), collection) in enumerate(zip(tool_definitions, all_tools), start=1):
|
||||
flattened_tools = _walk_collection(collection.TOOLS, full_title, full_title)
|
||||
active_tools = [tool for tool, _leaf, _top in flattened_tools if _tool_support_reason(tool) is None]
|
||||
archived_tools = [tool for tool, _leaf, _top in flattened_tools if getattr(tool, "ARCHIVED", False)]
|
||||
incompatible_tools = [
|
||||
tool for tool, _leaf, _top in flattened_tools
|
||||
if not getattr(tool, "ARCHIVED", False)
|
||||
and CURRENT_OS.system not in getattr(tool, "SUPPORTED_OS", ["linux", "macos"])
|
||||
]
|
||||
categories.append({
|
||||
"id": index,
|
||||
"title": full_title,
|
||||
"label": menu_label,
|
||||
"icon": icon,
|
||||
"description": getattr(collection, "DESCRIPTION", "") or "",
|
||||
"counts": {
|
||||
"active": len(active_tools),
|
||||
"archived": len(archived_tools),
|
||||
"incompatible": len(incompatible_tools),
|
||||
"installed": len([tool for tool in active_tools if getattr(tool, "is_installed", False)]),
|
||||
"total": len(flattened_tools),
|
||||
},
|
||||
"supportsInstallAll": any(hasattr(tool, "is_installed") and not getattr(tool, "ARCHIVED", False) for tool in active_tools),
|
||||
"collection": collection,
|
||||
"flattenedTools": flattened_tools,
|
||||
})
|
||||
return categories
|
||||
|
||||
|
||||
def get_categories_payload() -> list[dict]:
|
||||
categories = []
|
||||
for category in get_category_collections():
|
||||
categories.append({key: value for key, value in category.items() if key not in {"collection", "flattenedTools"}})
|
||||
return categories
|
||||
|
||||
|
||||
def get_tools_payload() -> list[dict]:
|
||||
tools = []
|
||||
tool_id = 1
|
||||
|
||||
for category in get_category_collections():
|
||||
for tool, leaf_title, top_level_title in category["flattenedTools"]:
|
||||
tools.append(_tool_to_dict(
|
||||
tool_id=tool_id,
|
||||
tool=tool,
|
||||
category_id=category["id"],
|
||||
category_title=leaf_title,
|
||||
category_label=category["label"],
|
||||
category_icon=category["icon"],
|
||||
top_level_category_title=top_level_title,
|
||||
))
|
||||
tool_id += 1
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def get_tool_by_id(tool_id: int) -> dict | None:
|
||||
for tool in get_tools_payload():
|
||||
if tool["id"] == tool_id:
|
||||
return tool
|
||||
return None
|
||||
|
||||
|
||||
def get_category_by_id(category_id: int) -> dict | None:
|
||||
for category in get_categories_payload():
|
||||
if category["id"] == category_id:
|
||||
return category
|
||||
return None
|
||||
|
||||
|
||||
def resolve_tool_instance(tool_id: int) -> tuple[ODKTool, dict] | tuple[None, None]:
|
||||
current_id = 1
|
||||
for category in get_category_collections():
|
||||
for tool, _leaf_title, _top_level_title in category["flattenedTools"]:
|
||||
if current_id == tool_id:
|
||||
return tool, category
|
||||
current_id += 1
|
||||
return None, None
|
||||
|
||||
|
||||
def resolve_category_collection(category_id: int) -> tuple[ODKToolsCollection, dict] | tuple[None, None]:
|
||||
for category in get_category_collections():
|
||||
if category["id"] == category_id:
|
||||
return category["collection"], category
|
||||
return None, None
|
||||
145
web/backend/main.py
Normal file
145
web/backend/main.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent
|
||||
ROOT_DIR = BACKEND_DIR.parent.parent
|
||||
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.append(str(BACKEND_DIR))
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.append(str(ROOT_DIR))
|
||||
|
||||
from catalog import get_categories_payload, get_category_by_id, get_tool_by_id, get_tools_payload
|
||||
from config import get_tools_dir
|
||||
from os_detect import CURRENT_OS
|
||||
|
||||
|
||||
app = FastAPI(title="Hacking Tool API")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def _run_backend_action(*args: str) -> dict:
|
||||
runner_path = BACKEND_DIR / "runner.py"
|
||||
started_at = datetime.now(timezone.utc)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(runner_path), *args],
|
||||
cwd=str(get_tools_dir()),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
)
|
||||
finished_at = datetime.now(timezone.utc)
|
||||
|
||||
return {
|
||||
"success": result.returncode == 0,
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"startedAt": started_at.isoformat(),
|
||||
"finishedAt": finished_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Welcome to Hacking Tool API"}
|
||||
|
||||
|
||||
@app.get("/api/system")
|
||||
def get_system():
|
||||
return {
|
||||
"os": {
|
||||
"system": CURRENT_OS.system,
|
||||
"distroId": CURRENT_OS.distro_id,
|
||||
"distroLike": CURRENT_OS.distro_like,
|
||||
"version": CURRENT_OS.distro_version,
|
||||
"packageManager": CURRENT_OS.pkg_manager,
|
||||
"isRoot": CURRENT_OS.is_root,
|
||||
"arch": CURRENT_OS.arch,
|
||||
"isWsl": CURRENT_OS.is_wsl,
|
||||
},
|
||||
"paths": {
|
||||
"toolsDir": str(get_tools_dir()),
|
||||
"backendDir": str(BACKEND_DIR),
|
||||
"repoRoot": str(ROOT_DIR),
|
||||
},
|
||||
"user": {
|
||||
"home": str(Path.home()),
|
||||
"name": os.environ.get("USER", os.environ.get("LOGNAME", "")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/categories")
|
||||
def get_categories():
|
||||
return get_categories_payload()
|
||||
|
||||
|
||||
@app.get("/api/categories/{category_id}")
|
||||
def get_category(category_id: int):
|
||||
category = get_category_by_id(category_id)
|
||||
if category is None:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
return category
|
||||
|
||||
|
||||
@app.post("/api/categories/{category_id}/actions/install-missing")
|
||||
def install_missing_in_category(category_id: int):
|
||||
category = get_category_by_id(category_id)
|
||||
if category is None:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
return _run_backend_action("--category-id", str(category_id), "--action", "install-missing")
|
||||
|
||||
|
||||
@app.get("/api/tools")
|
||||
def get_tools():
|
||||
return get_tools_payload()
|
||||
|
||||
|
||||
@app.get("/api/tools/{tool_id}")
|
||||
def get_tool(tool_id: int):
|
||||
tool = get_tool_by_id(tool_id)
|
||||
if tool is None:
|
||||
raise HTTPException(status_code=404, detail="Tool not found")
|
||||
return tool
|
||||
|
||||
|
||||
@app.post("/api/tools/{tool_id}/actions/{action_name}")
|
||||
def run_tool_action(tool_id: int, action_name: str):
|
||||
tool = get_tool_by_id(tool_id)
|
||||
if tool is None:
|
||||
raise HTTPException(status_code=404, detail="Tool not found")
|
||||
|
||||
if action_name not in {"install", "update", "uninstall", "run"}:
|
||||
raise HTTPException(status_code=400, detail="Unsupported action")
|
||||
|
||||
return _run_backend_action("--tool-id", str(tool_id), "--action", action_name)
|
||||
|
||||
|
||||
@app.post("/api/tools/{tool_id}/options/{option_index}")
|
||||
def run_tool_option(tool_id: int, option_index: int):
|
||||
tool = get_tool_by_id(tool_id)
|
||||
if tool is None:
|
||||
raise HTTPException(status_code=404, detail="Tool not found")
|
||||
|
||||
return _run_backend_action("--tool-id", str(tool_id), "--option-index", str(option_index))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
3
web/backend/requirements.txt
Normal file
3
web/backend/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fastapi==0.115.6
|
||||
uvicorn==0.34.0
|
||||
pydantic==2.10.4
|
||||
97
web/backend/runner.py
Normal file
97
web/backend/runner.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent
|
||||
ROOT_DIR = BACKEND_DIR.parent.parent
|
||||
|
||||
if str(BACKEND_DIR) not in sys.path:
|
||||
sys.path.append(str(BACKEND_DIR))
|
||||
if str(ROOT_DIR) not in sys.path:
|
||||
sys.path.append(str(ROOT_DIR))
|
||||
|
||||
from catalog import resolve_category_collection, resolve_tool_instance
|
||||
from config import get_tools_dir
|
||||
|
||||
|
||||
def _run_tool_action(tool_id: int, action: str, option_index: int | None) -> int:
|
||||
tool, _category = resolve_tool_instance(tool_id)
|
||||
if tool is None:
|
||||
print(f"Tool {tool_id} not found.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
os.chdir(str(get_tools_dir()))
|
||||
|
||||
if option_index is not None:
|
||||
if option_index < 0 or option_index >= len(tool.OPTIONS):
|
||||
print(f"Option {option_index} not found for tool {tool_id}.", file=sys.stderr)
|
||||
return 2
|
||||
label, callback = tool.OPTIONS[option_index]
|
||||
print(f"==> {tool.TITLE} :: {label}")
|
||||
callback()
|
||||
return 0
|
||||
|
||||
actions = {
|
||||
"install": tool.install,
|
||||
"update": tool.update,
|
||||
"uninstall": tool.uninstall,
|
||||
"run": tool.run,
|
||||
}
|
||||
|
||||
if action not in actions:
|
||||
print(f"Unsupported action: {action}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(f"==> {tool.TITLE} :: {action}")
|
||||
actions[action]()
|
||||
return 0
|
||||
|
||||
|
||||
def _run_category_action(category_id: int, action: str) -> int:
|
||||
_collection, category = resolve_category_collection(category_id)
|
||||
if _collection is None:
|
||||
print(f"Category {category_id} not found.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
os.chdir(str(get_tools_dir()))
|
||||
|
||||
if action != "install-missing":
|
||||
print(f"Unsupported category action: {action}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
pending_tools = [
|
||||
tool for tool, _leaf_title, _top_title in category["flattenedTools"]
|
||||
if not getattr(tool, "ARCHIVED", False)
|
||||
and hasattr(tool, "is_installed")
|
||||
and tool.is_installed is False
|
||||
]
|
||||
print(f"==> {category['label']} :: install-missing ({len(pending_tools)} tools)")
|
||||
|
||||
for index, tool in enumerate(pending_tools, start=1):
|
||||
print(f"\n[{index}/{len(pending_tools)}] {tool.TITLE}")
|
||||
tool.install()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run Hacking Tool backend actions.")
|
||||
parser.add_argument("--tool-id", type=int)
|
||||
parser.add_argument("--category-id", type=int)
|
||||
parser.add_argument("--action", type=str)
|
||||
parser.add_argument("--option-index", type=int)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.tool_id:
|
||||
return _run_tool_action(args.tool_id, args.action or "", args.option_index)
|
||||
if args.category_id:
|
||||
return _run_category_action(args.category_id, args.action or "")
|
||||
|
||||
print("Either --tool-id or --category-id is required.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
24
web/frontend/.gitignore
vendored
Normal file
24
web/frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
16
web/frontend/README.md
Normal file
16
web/frontend/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
21
web/frontend/eslint.config.js
Normal file
21
web/frontend/eslint.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
])
|
||||
16
web/frontend/index.html
Normal file
16
web/frontend/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hacking Tool</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3328
web/frontend/package-lock.json
generated
Normal file
3328
web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
web/frontend/package.json
Normal file
31
web/frontend/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"postcss": "^8.5.13",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
6
web/frontend/postcss.config.js
Normal file
6
web/frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
web/frontend/public/favicon.svg
Normal file
1
web/frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
web/frontend/public/icons.svg
Normal file
24
web/frontend/public/icons.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
184
web/frontend/src/App.css
Normal file
184
web/frontend/src/App.css
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
835
web/frontend/src/App.jsx
Normal file
835
web/frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
LayoutDashboard,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
Terminal,
|
||||
Target,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
const API_BASE = 'http://localhost:8000'
|
||||
|
||||
const visibilityModes = [
|
||||
{ id: 'active', label: 'Active' },
|
||||
{ id: 'installed', label: 'Installed' },
|
||||
{ id: 'archived', label: 'Archived' },
|
||||
{ id: 'incompatible', label: 'Unsupported' },
|
||||
{ id: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
const requirementLabels = {
|
||||
root: 'Root',
|
||||
wifi: 'Wi-Fi',
|
||||
go: 'Go',
|
||||
ruby: 'Ruby',
|
||||
java: 'Java',
|
||||
docker: 'Docker',
|
||||
}
|
||||
|
||||
function CategoryGlyph({ index }) {
|
||||
if (index % 4 === 0) return <Globe className="w-5 h-5" />
|
||||
if (index % 3 === 0) return <Terminal className="w-5 h-5" />
|
||||
if (index % 2 === 0) return <Shield className="w-5 h-5" />
|
||||
return <Settings className="w-5 h-5" />
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [systemInfo, setSystemInfo] = useState(null)
|
||||
const [categories, setCategories] = useState([])
|
||||
const [tools, setTools] = useState([])
|
||||
const [activeTab, setActiveTab] = useState('dashboard')
|
||||
const [targetScope, setTargetScope] = useState('*.example.com')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState(null)
|
||||
const [visibilityFilter, setVisibilityFilter] = useState('active')
|
||||
const [selectedToolId, setSelectedToolId] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [actionState, setActionState] = useState({
|
||||
loading: false,
|
||||
title: '',
|
||||
output: '',
|
||||
error: '',
|
||||
})
|
||||
|
||||
const loadCatalog = async (toolToReselect = null) => {
|
||||
setIsLoading(true)
|
||||
setLoadError('')
|
||||
try {
|
||||
const [systemRes, categoriesRes, toolsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/system`),
|
||||
fetch(`${API_BASE}/api/categories`),
|
||||
fetch(`${API_BASE}/api/tools`),
|
||||
])
|
||||
|
||||
if (!systemRes.ok || !categoriesRes.ok || !toolsRes.ok) {
|
||||
throw new Error('Failed to load backend catalog.')
|
||||
}
|
||||
|
||||
const [systemData, categoriesData, toolsData] = await Promise.all([
|
||||
systemRes.json(),
|
||||
categoriesRes.json(),
|
||||
toolsRes.json(),
|
||||
])
|
||||
|
||||
setSystemInfo(systemData)
|
||||
setCategories(categoriesData)
|
||||
setTools(toolsData)
|
||||
|
||||
if (toolToReselect) {
|
||||
const refreshedTool = toolsData.find((tool) => tool.id === toolToReselect)
|
||||
setSelectedToolId(refreshedTool ? refreshedTool.id : null)
|
||||
}
|
||||
} catch (error) {
|
||||
setLoadError(error.message || 'Failed to load frontend data.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadCatalog()
|
||||
}, [])
|
||||
|
||||
const selectedCategory = categories.find((category) => category.id === selectedCategoryId) || null
|
||||
const selectedTool = tools.find((tool) => tool.id === selectedToolId) || null
|
||||
|
||||
const openRegistry = ({ categoryId = null, visibility = 'active' } = {}) => {
|
||||
setSelectedCategoryId(categoryId)
|
||||
setVisibilityFilter(visibility)
|
||||
setActiveTab('tools')
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedCategoryId(null)
|
||||
setVisibilityFilter('active')
|
||||
setSearchQuery('')
|
||||
}
|
||||
|
||||
const filteredTools = tools.filter((tool) => {
|
||||
const matchesSearch =
|
||||
tool.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.topLevelCategory.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
tool.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
|
||||
const matchesCategory = !selectedCategoryId || tool.categoryId === selectedCategoryId
|
||||
|
||||
let matchesVisibility = true
|
||||
if (visibilityFilter === 'active') {
|
||||
matchesVisibility = !tool.archived && tool.compatible
|
||||
} else if (visibilityFilter === 'installed') {
|
||||
matchesVisibility = tool.installed
|
||||
} else if (visibilityFilter === 'archived') {
|
||||
matchesVisibility = tool.archived
|
||||
} else if (visibilityFilter === 'incompatible') {
|
||||
matchesVisibility = !tool.archived && !tool.compatible
|
||||
}
|
||||
|
||||
return matchesSearch && matchesCategory && matchesVisibility
|
||||
})
|
||||
|
||||
const installedToolsCount = tools.filter((tool) => tool.installed).length
|
||||
const archivedToolsCount = tools.filter((tool) => tool.archived).length
|
||||
const incompatibleToolsCount = tools.filter((tool) => !tool.archived && !tool.compatible).length
|
||||
|
||||
const selectedCategorySupportsInstallAll =
|
||||
selectedCategory && selectedCategory.supportsInstallAll && visibilityFilter !== 'archived'
|
||||
|
||||
const executeAction = async ({ url, title, toolIdToReselect = selectedToolId }) => {
|
||||
setActionState({
|
||||
loading: true,
|
||||
title,
|
||||
output: '',
|
||||
error: '',
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method: 'POST' })
|
||||
const payload = await response.json()
|
||||
const combinedOutput = [payload.stdout, payload.stderr].filter(Boolean).join('\n')
|
||||
|
||||
if (!response.ok || payload.success === false) {
|
||||
setActionState({
|
||||
loading: false,
|
||||
title,
|
||||
output: combinedOutput,
|
||||
error: payload.detail || 'Action failed.',
|
||||
})
|
||||
} else {
|
||||
setActionState({
|
||||
loading: false,
|
||||
title,
|
||||
output: combinedOutput || 'Action completed without console output.',
|
||||
error: '',
|
||||
})
|
||||
}
|
||||
|
||||
await loadCatalog(toolIdToReselect)
|
||||
} catch (error) {
|
||||
setActionState({
|
||||
loading: false,
|
||||
title,
|
||||
output: '',
|
||||
error: error.message || 'Action failed.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const runToolAction = (tool, actionName, label) =>
|
||||
executeAction({
|
||||
url: `${API_BASE}/api/tools/${tool.id}/actions/${actionName}`,
|
||||
title: `${label} · ${tool.title}`,
|
||||
toolIdToReselect: tool.id,
|
||||
})
|
||||
|
||||
const runToolOption = (tool, option) =>
|
||||
executeAction({
|
||||
url: `${API_BASE}/api/tools/${tool.id}/options/${option.index}`,
|
||||
title: `${option.label} · ${tool.title}`,
|
||||
toolIdToReselect: tool.id,
|
||||
})
|
||||
|
||||
const installMissingForCategory = (category) =>
|
||||
executeAction({
|
||||
url: `${API_BASE}/api/categories/${category.id}/actions/install-missing`,
|
||||
title: `Install Missing · ${category.label}`,
|
||||
toolIdToReselect: selectedToolId,
|
||||
})
|
||||
|
||||
const openProjectPage = (tool) => {
|
||||
if (tool.projectUrl) {
|
||||
window.open(tool.projectUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
const copyLocalPath = async (tool) => {
|
||||
if (!tool.localPath || !navigator.clipboard) return
|
||||
await navigator.clipboard.writeText(tool.localPath)
|
||||
setActionState({
|
||||
loading: false,
|
||||
title: `Path copied · ${tool.title}`,
|
||||
output: tool.localPath,
|
||||
error: '',
|
||||
})
|
||||
}
|
||||
|
||||
const renderActionOutput = () => {
|
||||
if (!actionState.title && !actionState.loading && !actionState.error && !actionState.output) return null
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-mono uppercase tracking-[0.2em] text-muted">Action Console</div>
|
||||
<div className="text-sm font-medium mt-2">{actionState.title || 'Latest action'}</div>
|
||||
</div>
|
||||
{actionState.loading && <RefreshCw className="w-4 h-4 animate-spin text-primary" />}
|
||||
</div>
|
||||
{actionState.error && (
|
||||
<div className="mt-4 border border-divider rounded-md p-3 text-sm text-red-300 bg-[#160909]">
|
||||
{actionState.error}
|
||||
</div>
|
||||
)}
|
||||
<pre className="mt-4 bg-background border border-divider rounded-md p-4 text-xs text-muted overflow-x-auto whitespace-pre-wrap">
|
||||
{actionState.output || (actionState.loading ? 'Working...' : 'No output yet.')}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderToolActionButtons = (tool) => {
|
||||
const supportedOptions = tool.options.filter((option) => option.webSupported)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{supportedOptions.map((option) => {
|
||||
if (option.kind === 'install') {
|
||||
return (
|
||||
<button
|
||||
key={`${tool.id}-${option.index}`}
|
||||
type="button"
|
||||
onClick={() => runToolAction(tool, 'install', option.label)}
|
||||
className="btn-primary text-xs uppercase tracking-wider font-mono"
|
||||
disabled={actionState.loading}
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (option.kind === 'update') {
|
||||
return (
|
||||
<button
|
||||
key={`${tool.id}-${option.index}`}
|
||||
type="button"
|
||||
onClick={() => runToolAction(tool, 'update', option.label)}
|
||||
className="btn-secondary text-xs uppercase tracking-wider font-mono"
|
||||
disabled={actionState.loading}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (option.kind === 'uninstall') {
|
||||
return (
|
||||
<button
|
||||
key={`${tool.id}-${option.index}`}
|
||||
type="button"
|
||||
onClick={() => runToolAction(tool, 'uninstall', option.label)}
|
||||
className="btn-secondary text-xs uppercase tracking-wider font-mono"
|
||||
disabled={actionState.loading}
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (option.kind === 'run') {
|
||||
return (
|
||||
<button
|
||||
key={`${tool.id}-${option.index}`}
|
||||
type="button"
|
||||
onClick={() => runToolAction(tool, 'run', option.label)}
|
||||
className="btn-secondary text-xs uppercase tracking-wider font-mono"
|
||||
disabled={actionState.loading}
|
||||
>
|
||||
Run
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${tool.id}-${option.index}`}
|
||||
type="button"
|
||||
onClick={() => runToolOption(tool, option)}
|
||||
className="btn-secondary text-xs uppercase tracking-wider font-mono"
|
||||
disabled={actionState.loading}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{tool.projectUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openProjectPage(tool)}
|
||||
className="btn-secondary text-xs uppercase tracking-wider font-mono"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Project
|
||||
</button>
|
||||
)}
|
||||
|
||||
{tool.localPath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyLocalPath(tool)}
|
||||
className="btn-secondary text-xs uppercase tracking-wider font-mono"
|
||||
>
|
||||
<FolderOpen className="w-3 h-3" />
|
||||
Path
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderToolDetailModal = () => {
|
||||
if (!selectedTool) return null
|
||||
|
||||
const requirementBadges = Object.entries(selectedTool.requires).filter(([, enabled]) => enabled)
|
||||
const unsupportedOptions = selectedTool.options.filter((option) => !option.webSupported)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm p-4 md:p-8 overflow-y-auto">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="card p-0 overflow-hidden">
|
||||
<div className="border-b border-divider p-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.25em] text-muted">
|
||||
{selectedTool.categoryLabel} · {selectedTool.category}
|
||||
</div>
|
||||
<h2 className="text-2xl font-display uppercase tracking-wider mt-3">{selectedTool.title}</h2>
|
||||
<p className="text-sm text-muted mt-3 max-w-3xl leading-relaxed">
|
||||
{selectedTool.description || 'No detailed description is defined for this tool yet.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedToolId(null)}
|
||||
className="btn-secondary px-3 py-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted">Status</div>
|
||||
<div className="mt-3 text-sm">
|
||||
{selectedTool.installed ? 'Installed' : 'Not installed'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted">Compatibility</div>
|
||||
<div className="mt-3 text-sm">
|
||||
{selectedTool.compatible ? 'Supported here' : selectedTool.supportReason}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted">Top Level</div>
|
||||
<div className="mt-3 text-sm">{selectedTool.topLevelCategory}</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted">Supported OS</div>
|
||||
<div className="mt-3 text-sm">{selectedTool.supportedOs.join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(selectedTool.archived || selectedTool.archivedReason) && (
|
||||
<div className="border border-divider rounded-lg p-4 bg-[#15120c]">
|
||||
<div className="flex items-center gap-2 text-sm uppercase tracking-[0.2em] text-yellow-300 font-mono">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Archived Tool
|
||||
</div>
|
||||
<p className="text-sm text-yellow-100/80 mt-3">
|
||||
{selectedTool.archivedReason || 'This tool is archived and may be unmaintained.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted mb-3">Web Actions</div>
|
||||
{renderToolActionButtons(selectedTool)}
|
||||
</div>
|
||||
|
||||
{requirementBadges.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted mb-3">Requirements</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{requirementBadges.map(([key]) => (
|
||||
<span key={key} className="badge">
|
||||
{requirementLabels[key]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTool.tags.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted mb-3">Tags</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTool.tags.map((tag) => (
|
||||
<span key={tag} className="badge">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted">Install Commands</div>
|
||||
<pre className="mt-4 text-xs text-muted whitespace-pre-wrap overflow-x-auto">
|
||||
{selectedTool.commands.install.length > 0 ? selectedTool.commands.install.join('\n') : 'No install commands defined.'}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted">Run Commands</div>
|
||||
<pre className="mt-4 text-xs text-muted whitespace-pre-wrap overflow-x-auto">
|
||||
{selectedTool.commands.run.length > 0 ? selectedTool.commands.run.join('\n') : 'No direct run commands defined.'}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted">Uninstall Commands</div>
|
||||
<pre className="mt-4 text-xs text-muted whitespace-pre-wrap overflow-x-auto">
|
||||
{selectedTool.commands.uninstall.length > 0 ? selectedTool.commands.uninstall.join('\n') : 'No uninstall commands defined.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted">Local Path</div>
|
||||
<div className="mt-4 text-sm break-all">{selectedTool.localPath || 'Tool path not detected yet.'}</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted">Project URL</div>
|
||||
<div className="mt-4 text-sm break-all">{selectedTool.projectUrl || 'No project URL supplied.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unsupportedOptions.length > 0 && (
|
||||
<div className="card p-4">
|
||||
<div className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted">CLI-Only Actions</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{unsupportedOptions.map((option) => (
|
||||
<span key={`${selectedTool.id}-${option.index}`} className="badge">
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted mt-4">
|
||||
These actions are exposed from the CLI but still need a better web execution flow because they open shells,
|
||||
prompt for input, or launch external apps.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-text flex flex-col font-sans">
|
||||
<header className="border-b border-divider bg-background sticky top-0 z-40">
|
||||
<div className="max-w-[1500px] mx-auto px-6 h-16 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 border border-divider flex items-center justify-center rounded">
|
||||
<Shield className="w-6 h-6 text-primary" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-display font-bold text-lg tracking-tight uppercase leading-none">Hacking Tool</span>
|
||||
<span className="text-[10px] text-muted font-mono tracking-widest uppercase mt-1">Frontend Control Plane</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center space-x-1 border border-divider p-1 rounded">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('dashboard')}
|
||||
className={`px-4 py-1.5 rounded text-sm font-medium transition-all flex items-center gap-2 ${activeTab === 'dashboard' ? 'bg-primary text-black' : 'text-muted hover:text-primary hover:bg-surfaceHover'}`}
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('tools')}
|
||||
className={`px-4 py-1.5 rounded text-sm font-medium transition-all flex items-center gap-2 ${activeTab === 'tools' ? 'bg-primary text-black' : 'text-muted hover:text-primary hover:bg-surfaceHover'}`}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Registry
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 max-w-[1500px] w-full mx-auto px-6 py-8 space-y-8">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[1.4fr_1fr] gap-4">
|
||||
<div className="card p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<Target className="w-5 h-5 text-muted" />
|
||||
<div className="flex flex-col flex-1 sm:w-72">
|
||||
<label className="text-[10px] uppercase font-mono text-muted mb-1">Authorized Target Scope</label>
|
||||
<input
|
||||
type="text"
|
||||
value={targetScope}
|
||||
onChange={(event) => setTargetScope(event.target.value)}
|
||||
className="bg-transparent border-b border-dividerHover focus:border-primary outline-none text-sm font-mono pb-1 transition-colors"
|
||||
placeholder="IP, Domain, or CIDR"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 border border-divider px-3 py-1.5 rounded bg-background">
|
||||
<div className="w-2 h-2 rounded-full bg-success animate-pulse"></div>
|
||||
<span className="text-xs font-mono text-muted uppercase">System Armed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 flex flex-col justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase font-mono text-muted">Execution Context</div>
|
||||
<div className="mt-3 text-sm">
|
||||
{systemInfo ? `${systemInfo.os.system} · ${systemInfo.os.packageManager || 'manual pkg manager'}` : 'Loading system profile...'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-mono text-muted break-all">
|
||||
{systemInfo ? systemInfo.paths.toolsDir : 'Fetching tools directory...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="border border-divider rounded-lg p-4 bg-[#160909] text-red-200">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderActionOutput()}
|
||||
|
||||
{activeTab === 'dashboard' && (
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-4">
|
||||
<button type="button" onClick={() => openRegistry({ visibility: 'all' })} className="card text-left group cursor-pointer">
|
||||
<div className="flex items-center justify-between mb-2 text-muted">
|
||||
<span className="text-xs uppercase tracking-wider font-mono">Modules</span>
|
||||
<Terminal className="w-4 h-4 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="text-3xl font-display">{tools.length || '--'}</div>
|
||||
<div className="mt-3 text-[11px] uppercase tracking-[0.2em] text-muted">Full inventory</div>
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={() => openRegistry({ visibility: 'all' })} className="card text-left group cursor-pointer">
|
||||
<div className="flex items-center justify-between mb-2 text-muted">
|
||||
<span className="text-xs uppercase tracking-wider font-mono">Categories</span>
|
||||
<LayoutDashboard className="w-4 h-4 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="text-3xl font-display">{categories.length || '--'}</div>
|
||||
<div className="mt-3 text-[11px] uppercase tracking-[0.2em] text-muted">Top-level vectors</div>
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={() => openRegistry({ visibility: 'installed' })} className="card text-left group cursor-pointer">
|
||||
<div className="flex items-center justify-between mb-2 text-muted">
|
||||
<span className="text-xs uppercase tracking-wider font-mono">Installed</span>
|
||||
<Download className="w-4 h-4 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="text-3xl font-display">{installedToolsCount}</div>
|
||||
<div className="mt-3 text-[11px] uppercase tracking-[0.2em] text-muted">Ready now</div>
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={() => openRegistry({ visibility: 'archived' })} className="card text-left group cursor-pointer">
|
||||
<div className="flex items-center justify-between mb-2 text-muted">
|
||||
<span className="text-xs uppercase tracking-wider font-mono">Archived</span>
|
||||
<AlertTriangle className="w-4 h-4 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="text-3xl font-display">{archivedToolsCount}</div>
|
||||
<div className="mt-3 text-[11px] uppercase tracking-[0.2em] text-muted">Legacy modules</div>
|
||||
</button>
|
||||
|
||||
<button type="button" onClick={() => openRegistry({ visibility: 'incompatible' })} className="card text-left group cursor-pointer">
|
||||
<div className="flex items-center justify-between mb-2 text-muted">
|
||||
<span className="text-xs uppercase tracking-wider font-mono">Unsupported</span>
|
||||
<Wrench className="w-4 h-4 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<div className="text-3xl font-display">{incompatibleToolsCount}</div>
|
||||
<div className="mt-3 text-[11px] uppercase tracking-[0.2em] text-muted">Hidden in CLI</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-mono uppercase tracking-widest text-muted mb-4 flex items-center gap-2">
|
||||
<Network className="w-4 h-4" /> Tactical Vectors
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{categories.map((category, index) => (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => openRegistry({ categoryId: category.id, visibility: 'active' })}
|
||||
className="card text-left group cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="text-muted group-hover:text-primary transition-colors">
|
||||
<CategoryGlyph index={index} />
|
||||
</div>
|
||||
<div className="text-right text-[10px] font-mono uppercase tracking-[0.2em] text-muted">
|
||||
{category.counts.installed}/{category.counts.total} installed
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 text-base font-display uppercase tracking-wide">{category.label}</div>
|
||||
<div className="mt-2 text-sm text-muted leading-relaxed">
|
||||
{category.description || category.title}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-[10px] font-mono uppercase tracking-[0.2em] text-muted">
|
||||
<span>{category.counts.active} active</span>
|
||||
<span>{category.counts.archived} archived</span>
|
||||
<span>{category.counts.incompatible} unsupported</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tools' && (
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
<div className="flex flex-col lg:flex-row lg:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-display uppercase tracking-wider">Module Registry</h2>
|
||||
<p className="text-xs font-mono uppercase tracking-[0.2em] text-muted mt-2">
|
||||
{selectedCategory ? `${selectedCategory.label} · ` : ''}
|
||||
{visibilityModes.find((mode) => mode.id === visibilityFilter)?.label} · {filteredTools.length} visible
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tools, tags, or categories..."
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
className="w-full bg-surface border border-divider rounded-md pl-9 pr-4 py-2 text-sm focus:border-primary outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedCategorySupportsInstallAll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => installMissingForCategory(selectedCategory)}
|
||||
className="btn-primary text-xs uppercase tracking-wider font-mono"
|
||||
disabled={actionState.loading}
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
Install Missing
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibilityModes.map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
onClick={() => setVisibilityFilter(mode.id)}
|
||||
className={`btn-secondary px-3 py-1.5 text-xs uppercase tracking-wider font-mono ${visibilityFilter === mode.id ? 'border-primary text-primary' : ''}`}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{(selectedCategoryId || searchQuery || visibilityFilter !== 'active') && (
|
||||
<button type="button" onClick={clearFilters} className="btn-secondary px-3 py-1.5 text-xs uppercase tracking-wider font-mono">
|
||||
<X className="w-3 h-3" />
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryId(null)}
|
||||
className={`btn-secondary px-3 py-1.5 text-xs uppercase tracking-wider font-mono ${selectedCategoryId === null ? 'border-primary text-primary' : ''}`}
|
||||
>
|
||||
All Categories
|
||||
</button>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryId(category.id)}
|
||||
className={`btn-secondary px-3 py-1.5 text-xs uppercase tracking-wider font-mono ${selectedCategoryId === category.id ? 'border-primary text-primary' : ''}`}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="card py-10 text-center text-sm text-muted">Refreshing registry...</div>
|
||||
) : filteredTools.length === 0 ? (
|
||||
<div className="card py-10 text-center">
|
||||
<div className="text-lg font-display uppercase tracking-wider">No tools match this filter</div>
|
||||
<p className="text-sm text-muted mt-3">Try another visibility mode, clear the search, or switch categories.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filteredTools.map((tool) => (
|
||||
<div key={tool.id} className="card flex flex-col p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<span className="badge">{tool.categoryLabel}</span>
|
||||
{tool.category !== tool.topLevelCategory && <span className="badge">{tool.category}</span>}
|
||||
{tool.installed && <span className="badge">Installed</span>}
|
||||
{tool.archived && <span className="badge">Archived</span>}
|
||||
{!tool.archived && !tool.compatible && <span className="badge">Unsupported</span>}
|
||||
</div>
|
||||
<h3 className="font-bold text-base font-display uppercase tracking-wide">{tool.title}</h3>
|
||||
</div>
|
||||
<div className={`${tool.installed ? 'text-primary' : 'text-muted'}`}>
|
||||
{tool.installed ? <CheckCircle2 className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted mt-4 leading-relaxed flex-grow">
|
||||
{tool.description || 'No description available for this module.'}
|
||||
</p>
|
||||
|
||||
{tool.tags.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{tool.tags.slice(0, 4).map((tag) => (
|
||||
<span key={tag} className="badge">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!tool.compatible && (
|
||||
<div className="mt-4 text-xs text-yellow-200/80 border border-divider rounded-md p-3 bg-[#15120c]">
|
||||
{tool.supportReason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{tool.actions.canInstall && !tool.installed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runToolAction(tool, 'install', 'Install')}
|
||||
className="btn-primary text-xs uppercase tracking-wider font-mono"
|
||||
disabled={actionState.loading}
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
{tool.actions.canUpdate && tool.installed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runToolAction(tool, 'update', 'Update')}
|
||||
className="btn-secondary text-xs uppercase tracking-wider font-mono"
|
||||
disabled={actionState.loading}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedToolId(tool.id)}
|
||||
className="btn-secondary text-xs uppercase tracking-wider font-mono"
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{renderToolDetailModal()}
|
||||
|
||||
<footer className="py-6 border-t border-divider text-center text-xs font-mono text-muted">
|
||||
support: <a href="https://github.com/imranshiundu/open-defense-kit" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">ODK</a>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
BIN
web/frontend/src/assets/hero.png
Normal file
BIN
web/frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
web/frontend/src/assets/react.svg
Normal file
1
web/frontend/src/assets/react.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4 KiB |
1
web/frontend/src/assets/vite.svg
Normal file
1
web/frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
57
web/frontend/src/index.css
Normal file
57
web/frontend/src/index.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply font-sans antialiased;
|
||||
background-color: theme('colors.background');
|
||||
color: theme('colors.text');
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: theme('colors.primary');
|
||||
color: theme('colors.black');
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply border rounded-lg p-6 transition-colors duration-200;
|
||||
background-color: theme('colors.surface');
|
||||
border-color: theme('colors.divider');
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background-color: theme('colors.surfaceHover');
|
||||
border-color: theme('colors.dividerHover');
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply font-medium py-2 px-4 rounded-md transition-colors flex items-center justify-center gap-2;
|
||||
background-color: theme('colors.primary');
|
||||
color: theme('colors.black');
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: theme('colors.primaryHover');
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border font-medium py-2 px-4 rounded-md transition-colors flex items-center justify-center gap-2;
|
||||
background-color: transparent;
|
||||
color: theme('colors.text');
|
||||
border-color: theme('colors.divider');
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: theme('colors.surfaceHover');
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply px-2.5 py-0.5 rounded-md text-xs font-mono border uppercase tracking-wider;
|
||||
background-color: theme('colors.surface');
|
||||
color: theme('colors.muted');
|
||||
border-color: theme('colors.divider');
|
||||
}
|
||||
}
|
||||
10
web/frontend/src/main.jsx
Normal file
10
web/frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
30
web/frontend/tailwind.config.js
Normal file
30
web/frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: '#030303',
|
||||
surface: '#0d0d0d',
|
||||
surfaceHover: '#161616',
|
||||
divider: '#222222',
|
||||
dividerHover: '#444444',
|
||||
primary: '#ffffff',
|
||||
primaryHover: '#e0e0e0',
|
||||
text: '#f5f5f5',
|
||||
muted: '#888888',
|
||||
danger: '#ff4444',
|
||||
success: '#ffffff',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['"JetBrains Mono"', 'monospace'],
|
||||
display: ['"JetBrains Mono"', 'monospace'],
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
7
web/frontend/vite.config.js
Normal file
7
web/frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
Reference in a new issue