diff --git a/.gitignore b/.gitignore index 5b3692f..bfc8e69 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ dashboard/.env.*.local /klalb-config.json /klalbconfig-old.json + +# Local agent workflow state +.slim/deepwork/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 75587bd..822200a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -22,6 +22,20 @@ "${workspaceFolder}/lib/*" ], "vmArgs": "--enable-native-access=ALL-UNNAMED --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED" + }, + { + "type": "java", + "name": "KLALB JavaFX Preview (bin)", + "request": "launch", + "mainClass": "org.kne.cloud.network.klalb.KLALBMain", + "cwd": "${workspaceFolder}", + "classPaths": [ + "${workspaceFolder}/bin", + "${workspaceFolder}/src", + "${workspaceFolder}/lib/*" + ], + "args": "--javafx", + "vmArgs": "--enable-native-access=ALL-UNNAMED,javafx.graphics --add-opens=java.base/jdk.internal.misc=ALL-UNNAMED --add-modules=javafx.controls,javafx.graphics -Djava.library.path=${workspaceFolder}" } ] } diff --git a/AGENTS.md b/AGENTS.md index 16377ee..813345d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,101 +1,42 @@ -# AGENTS.md +# KLALB Agent Guide -KLALB ("KLALB Decentralized SRv6 Network") — Java load-balancing/tunnel system that merges multiple WAN links into one virtual IPv6/SRv6 network. Version constant lives in `src/org/kne/cloud/network/klalb/CONST.java`. Protocol specs and manuals are the Chinese `.docx` files in the repo root. +KLALB is a Java SRv6/WAN load-balancing system. `KLALBMain` loads `klalb-config.json` from the current directory, builds `KLALBProxySystem`, optionally opens a desktop UI, optionally starts the web UI, and always binds the virtual server to `[::]:4564`. -## Build & run +## Java Build And Run -No Maven/Gradle. Plain Eclipse/IntelliJ project: dependencies are vendored jars in `lib/`, output goes to `bin/` (gitignored). When adding a jar, update **both** `.classpath` and `KLALB.iml`. - -Compile (`javac` is NOT on PATH — use the full JDK path; `-encoding UTF-8` is mandatory because sources contain Chinese text): +- This is a plain Java project: vendored dependencies are in `lib/`, `bin/` is generated, and sources/resources are in `src/`. There is no Maven, Gradle, test suite, or CI. +- VSCode is configured for `C:\Program Files\Zulu\zulu-25`. `javac` is not on `PATH`; source files require UTF-8 and now reference JavaFX modules. ```powershell -& "C:\Program Files\Zulu\zulu-25\bin\javac.exe" -encoding UTF-8 -cp "lib/*" -d bin (Get-ChildItem -Recurse src -Filter *.java | ForEach-Object FullName) +& "C:\Program Files\Zulu\zulu-25\bin\javac.exe" -encoding UTF-8 --add-modules=javafx.controls,javafx.graphics -cp "lib/*" -d bin (Get-ChildItem -Recurse src -Filter *.java | ForEach-Object FullName) ``` -Warnings about `ThreadTool` varargs / deprecated `finalize` are pre-existing and expected — success = exit code 0. After recompiling, restart the running app (IDE-debugged JVMs keep old classes). +- The documented `ThreadTool` varargs and deprecated-API warnings are pre-existing; compilation succeeds with exit code 0. Restart a debugged JVM after compiling because it keeps old classes. +- Run from the repository root. `src` must stay on the runtime classpath after manual compilation because it contains `klalb_*.properties` and `/assets/*` resources. +- Use `KLALB JavaFX Preview (bin)` in VSCode for the opt-in `--javafx` UI. Default startup remains Swing. The preview configuration supplies required JavaFX modules, native-access flags, and `java.library.path`. +- Native files are expected in the repository root. `fastcopy.dll` is currently absent, so `FastLib` logs a recoverable `UnsatisfiedLinkError`; restore the matching binary before testing native acceleration. Set `enableTUN: false` for non-admin UI testing; otherwise TUN creation requires elevation. -Run from the repo root — CWD matters: -- reads `klalb-config.json` from CWD -- loads native libs from CWD: `tuntap4j.dll/.so/.dylib`, `wintun.dll`, `fastcopy.dll` (TUN device support) -- classpath must include `src` as well as `bin`: i18n bundles (`/klalb_*.properties`) and images (`/assets/*`) are classpath resources that Eclipse copies to `bin` but manual `javac` does not +## Architecture And UI -```powershell -java --enable-native-access=ALL-UNNAMED "--add-opens=java.base/jdk.internal.misc=ALL-UNNAMED" -cp "bin;src;lib/*" org.kne.cloud.network.klalb.KLALBMain -``` +- Keep `KLALBController` and networking work off UI threads. The current JavaFX layer is `src/org/kne/cloud/network/klalb/ui/fx/`, starts only with `--javafx`, and exposes immutable snapshots/typed commands through `KLALBJavaFXGateway`; do not leak controller or link objects into JavaFX views. +- Swing remains in `...klalb.ui`. Do not assume feature parity: Overview and Connections are migrated; routing, topology, settings, and detailed monitor views remain Swing/placeholder workspaces. +- UI strings use the active `UIEnv` bundle. Add every new key to both `src/klalb_zh_CN.properties` and `src/klalb_en_US.properties`; keep them on the runtime classpath. -IDE metadata targets JDK 26 (`jdk-26.0.1`); the tree also compiles cleanly on JDK 25. `.classpath` now references the standard container `JavaSE-25` — an execution-environment spec that any JDK ≥25 satisfies, so it works unchanged on JDK 26 machines too (the original named `jdk-26.0.1` VM broke VS Code import on machines without it). `.vscode/settings.json` maps `JavaSE-25` to the locally installed Adoptium JDK; register every installed JDK there when adding another one. Keep compiler compliance ≤25 (`.settings` pins 19) so both JDKs stay usable. +## Config, APIs, And Protocols -Runtime gotchas (all verified): -- On JDK 25, `KNEOptimize.jar`'s `FastLib` reflects into `jdk.internal.misc.Unsafe`; without the two JVM flags above it throws `InaccessibleObjectException` at startup (app still runs). -- Creating the SRv6 TUN adapter (`WintunCreateAdapter`) requires an elevated shell; without admin rights it logs "创建虚拟网卡失败" and continues with only the `inLoopBack` interface — links/bridges still work. -- To disable TUN creation completely (e.g. for non-admin UI/routing testing), set `"enableTUN": false` in `klalb-config.json` or toggle off "启用 TUN 虚拟网卡" in GUI/Web settings. -- Routing broadcast (`RouterInfo`) transmits `deviceName`, which topology and node overview panels display. `deviceDescription` is NOT broadcast — it only leaves the node in full node-info query responses (see srv6 API below); `ExtraRoutes` remain local controller configs. +- `klalb-config.json` is a `Type`-discriminated list. New item types need a `KLALBConfigItem` subclass plus both default Gson serializer/deserializer registrations; unknown items must remain preservable. +- Config saves use canonical keys but reads and `/api/config` accept legacy aliases. Keep aliases in `KLALBConfigItem` and field-by-field `KLALBWebServer.handleConfig` parsing when changing controller settings; whole-object Gson mapping breaks polymorphic address lists. +- Gson is vendored version 2.1. For a runtime `JsonElement`, use `JsonElement.toString()` rather than `gson.toJson(Object)`; old Gson serializes subclasses incorrectly. Keep the existing guard in `sendJsonResponse`. +- SRv6 JSON datagrams have a two-byte UTF-8 payload length; keep encoded messages below 64 KiB. +- The web dashboard is served from `dashboard/dist` when `webUI` is enabled (normally `:4665`); without it, the backend serves fallback HTML. + +## Dashboard Submodule + +- `dashboard/` is a separate Git repository. Commit frontend work there first, then update the parent submodule pointer. +- Run frontend commands inside `dashboard/`: `pnpm dev`, `pnpm build`, `pnpm lint`, and `pnpm typecheck`. Vite proxies `/api` to `http://127.0.0.1:4665`. +- Install shadcn components only with `pnpm dlx shadcn@latest add `; use `pnpm add` for other dependencies. The dashboard README's `npx` instruction is stale. ## Verification -No test suite, no CI. Classes named `*Test*` (`nathole/`, `ntp/`) are manual `main()` harnesses requiring real network peers. Practical check = compile succeeds + app launches. - -## Architecture - -- Entrypoint `org.kne.cloud.network.klalb.KLALBMain`: load config → build `KLALBProxySystem` → open Swing GUI (`KLALBStateGUI3`) unless `"nogui": true` → start `KLALBWebServer` (if `"webUI": true` or web server enabled, default port `4665`) → interactive console (`help`, `links-state`, `route`, `kperf`, ...). -- `org.kne.cloud.network.klalb.web.KLALBWebServer` — built-in HTTP/SSE server (JDK `HttpServer`): - - API endpoints: `/api/status`, `/api/events` (SSE stream, 200ms intervals), `/api/links`, `/api/links/action`, `/api/links/reconnect`, `/api/routing-table`, `/api/nodes` (topology graph), `/api/node-info?address=` (on-demand full node info), `/api/config`. - - Static file hosting / SPA fallback: serves `dashboard/dist/` assets directly. -- `org.kne.cloud.network` — generic socket framework: `VirtualSocket*` hierarchy, `SocketBridge` port-forwarding proxies, `ProtocolDetector` (multi-protocol mux on one port), `MultiProtocolSocketAddress` = URI-style addresses (`tcp://`, `udp://`, `kltp://`, `ntp://`) dispatched through the `SocketType` registry. -- `...network.klalb` — app core: `KLALBController` (the virtual SRv6 network), `KLALBRemoteLink` (WAN lines), `*Packet` wire-format classes, virtual socket implementations. -- `...network.congestion` — pluggable congestion control (BBR, Vegas2, DCTCP...), chosen via `"congestionAlgorithm"` in config. -- `...network.kltp` — custom reliable transport protocol (packets/streams). -- `...network.ipv6`, `...network.srv6` — packet codecs, route table, Dijkstra path computation. -- **Node-info query API** (`...network.srv6`, JSON datagrams on `KLALBRoutingProtocol.DEFAULT_PORT=1001`): `KLALBRoutingProtocolAPIServer/Client` speak two request types — - - `nodeinfotinyreq/resp` → device name ONLY; never gated by any flag (name is public via broadcast anyway). - - `nodeinfofullreq/resp` → externalEndpoints + deviceName + deviceDescription. `denyExternalEndpointQuery=true` hides ONLY the endpoint list (`data=null`); name/description still answer. - - GUI rule: opening `NodeInformationPanel` = Full query; use `requestNodeInfoTiny` for lightweight/background lookups. Legacy `openlines*` message types were removed — mixed-version meshes get silence, so upgrade the whole network together. - - `JsonDataPacket` stores its UTF-8 payload length in a 2-byte header field: keep every JSON message under 64 KiB. -- `...network.frpc` — frp client integration. -- `...klalb.ui` — all Swing UI code. - -## Frontend (Dashboard) - -Located in `dashboard/`: -- **Git layout**: `dashboard/` is a separate git repo wired in as a submodule (own origin on `git.code.cq.cn`). Commit frontend changes inside `dashboard/` first, then bump the submodule pointer in the parent repo — parent-repo commits alone do not capture them. -- **Stack**: Vite + React 19 + TypeScript + Tailwind CSS v4 + `@base-ui/react` (style: `base-nova`, icons: `lucide-react`, toasts: `@base-ui/react/toast`). -- **Routing**: Hash-based routing (`#/overview`, `#/connections`, `#/routing-table`, `#/topology`, `#/settings`) for seamless SPA hosting under Java `KLALBWebServer`. -- **Package Manager**: `pnpm` (run all commands from `dashboard/` directory). -- **Component installation**: **Must** use CLI via `pnpm dlx shadcn@latest add ` (e.g. `pnpm dlx shadcn@latest add alert card badge toast`). Never create or fake shadcn components manually. Non-shadcn libs (`@xyflow/react`, `d3-force`) are installed via plain `pnpm add`. -- **Commands**: - - `pnpm dev` — Start Vite dev server (proxies `/api` to backend `http://127.0.0.1:4665`). - - `pnpm build` — Typecheck and build SPA to `dashboard/dist` (which Java `KLALBWebServer` serves directly). - - `pnpm lint` / `pnpm typecheck` — Verification. -- **Pages & data flow**: - - Overview / Connections read the SSE stream (`use-klalb-sse.ts`, 200ms pushes of status + links). - - Settings loads/saves `/api/config` (`use-klalb-config.ts`); save payload must keep legacy field aliases alongside new names for compatibility. - - Routing table polls `/api/routing-table` every 1s (`use-routing-table.ts`); `cost` is delay-derived and displayed in milliseconds. - - Topology polls `/api/nodes` every 1s (`use-topology.ts`) — SSE does NOT carry topology. - - Selecting a topology node queries `/api/node-info`; remote node descriptions require a full SRv6 node-info request and can time out after 3s. - - Topology layout: `d3-force` headless simulation (recomputed only when node/edge structure changes) rendered by `@xyflow/react` with custom `device-node` / `link-edge` components in `src/components/topology/`. - - React hooks lint rule forbids `setState` synchronously inside effects — initialize form state via component `key` remount + lazy `useState(() => ...)` initializers (see `SettingsForm` pattern). - -## Config - -`klalb-config.json` is an array of items discriminated by their `"Type"` field. Adding a new item type requires a `KLALBConfigItem` subclass **plus** new cases in both `KLALBConfigItem.getDefaultJsonDeserializer()` and `getDefaultJsonSerializer()`; unknown types are preserved as `UnknownKLALBConfigItem`. Any Gson instance handling config must register these adapters via `registerToGsonBuilder` (see `KLALBProxySystem`). - -Key controller config fields: -- `externalEndpoints` / `autoConnections`: published vs auto-connect endpoint lists (renamed from `openConnections`, which itself replaced legacy `LineTable`; the old name was a developer naming mistake — these addresses are this node's externally published endpoints, not "connections"). -- `ntpServers`: time server list (replaces `ntpServerTable`). -- `denyExternalEndpointQuery` / `denyExternalEndpointBroadcast`: safety flags — the query flag hides ONLY the external-endpoint list in full node-info responses (device name/description still answer; Tiny queries are never gated), the broadcast flag disables LAN multicast discovery (renamed from `denyConnectionQuery` / `denyConnectionBroadcast`, which replaced `denyLineTableQuery` / `denyLineTableBroadcast`). -- `enableTUN`: boolean flag for TUN interface creation (`"TUNName"` configures device name). -- `webListen`: Web API listen address, normally `http://0.0.0.0:4665`; legacy `webPort` is accepted on load/API input. - -Legacy JSON keys are still accepted on load: `KLALBConfigItem.getDefaultJsonDeserializer()` normalizes old key names (`openConnections`/`LineTable`, `denyConnectionQuery`, `denyLineTable*`, ...) before reflective deserialization (manual rewrite because gson-2.1 has no `@SerializedName(alternate=...)`), and `handleConfig` in the web server accepts them too. New saves always write canonical names. - -Gson quirks: -- **gson-2.1 (vendored) is ancient**: its `JSON_ELEMENT` adapter factory only matches exact `JsonElement.class`, NOT subclasses. Calling `gson.toJson(Object)` with a runtime `JsonObject`/`JsonArray` reflectively serializes the internal field as `{"members": {...}}`. `KLALBWebServer.sendJsonResponse` guards against this by using `JsonElement.toString()` for JsonElement instances — keep that guard when adding new response paths. SSE avoids the issue entirely via `JsonObject.toString()`. -- `/api/config` GET/POST is parsed field-by-field in `KLALBWebServer.handleConfig` (NOT whole-object Gson reflection) because polymorphic fields (`List`, `List`) break reflective mapping. Keep new config fields in sync there, accepting both legacy and new JSON key names. -- `InetAddress`, `MultiProtocolSocketAddress`, and `KLALBConfigItem` custom adapters are registered on the shared Gson in `KLALBProxySystem`; the web server reuses that instance via `proxySystem.getGson()`. -- Saving via web API persists through `KLALBProxySystem.saveConfigToFile()` (GUI save consumer takes precedence when present). - -## Conventions - -- Sources are UTF-8; comments, log/UI strings, and commit messages are largely Chinese. -- UI strings go through `UIEnv.getRsb().getString(...)`; add keys to **both** `src/klalb_zh_CN.properties` and `src/klalb_en_US.properties`. -- `client.cfg`, `server.cfg`, `linetable.txt` at the root are example line-table/port-rule files loaded via the GUI file picker — not hardwired paths. +- Practical Java verification is the full compile command above plus a controlled launch. Existing `*Test*` classes are manual harnesses that need real network peers. +- For dashboard changes, run `pnpm lint`, `pnpm typecheck`, and `pnpm build`. diff --git a/src/klalb_en_US.properties b/src/klalb_en_US.properties index e2bf3d3..d2ad9ff 100644 --- a/src/klalb_en_US.properties +++ b/src/klalb_en_US.properties @@ -117,3 +117,77 @@ enablewebapi=Enable Web API weblistenaddr=Web API listen address invaildweblistenaddr=Invalid Web API listen address enabletun=Enable TUN adapter +nav.overview=Overview +nav.connections=Connections +nav.routing=Routing +nav.topology=Topology +nav.settings=Settings +overview.title=Network overview +overview.subtitle=Live local node and data-plane status +overview.dataPlane=Data plane +overview.localAddress=Local node address +connections.title=Connections +connections.subtitle=Remote link status and controls +routing.title=Routing +topology.title=Topology +settings.title=Settings +connections.endpoint=Remote endpoint +connections.endpointPrompt=Enter endpoint address +connections.add=Add endpoint +connections.added=Endpoint added +connections.reconnectAll=Reconnect all +connections.reconnecting=Reconnecting +connections.showOffline=Show offline +connections.hideOffline=Hide offline +connections.empty=No connections to display +connections.reconnect=Reconnect +connections.disconnect=Force disconnect +connections.disconnected=Connection disconnected +connections.remove=Remove +connections.removed=Connection removed +connections.confirmTitle=Confirm connection removal +connections.confirmHeader=This removes connections matching the endpoint +state.online=Online +state.offline=Offline +metric.links=Links online / total +metric.totalThroughput=Total throughput +metric.totalPps=Total PPS +metric.upload=Upload +metric.download=Download +metric.pps=PPS +metric.up=up +metric.down=down +format.bytesPerSecond=B/s +format.kilobytesPerSecond=KB/s +format.megabytesPerSecond=MB/s +format.gigabytesPerSecond=GB/s +format.milliseconds=ms +copyHelp=Copy address to clipboard +shell.productHint=SRv6 network console +shell.workspace=Workspace +shell.awaitingGateway=Awaiting gateway +shell.noData=No runtime data connected +shell.footer=Runtime status · Data supplied by gateway +shell.connected=Gateway connected +shell.links=links +shell.error=Temporarily unavailable +shell.refreshFailed=Refresh failed. Try again later. +shell.pendingView=This view is scheduled for a later migration +metric.devices=Online devices +metric.totalTransferred=Transferred +overview.backplane=Backplane +overview.clock=Clock +metric.delay=Delay +metric.ppsMax=PPS peak +metric.ecnLoss=ECN / loss +metric.clockTime=Current time +metric.frequency=Frequency +metric.ppm=Frequency offset +format.percent=% +format.hz=Hz +format.ppm=PPM +state.unstable=Unstable +state.accessible=Connection state +connections.copyEndpoint=Copy endpoint address +connections.copyVirtual=Copy virtual address +connections.copyBind=Copy bind address diff --git a/src/klalb_zh_CN.properties b/src/klalb_zh_CN.properties index 9c46648..2a8015b 100644 --- a/src/klalb_zh_CN.properties +++ b/src/klalb_zh_CN.properties @@ -116,4 +116,78 @@ webapisettings=Web API 设置 enablewebapi=启用 Web API weblistenaddr=Web API 监听地址:端口 invaildweblistenaddr=无效的 Web API 监听地址:端口 -enabletun=启用TUN虚拟网卡 \ No newline at end of file +enabletun=启用TUN虚拟网卡 +nav.overview=概览 +nav.connections=连接 +nav.routing=路由 +nav.topology=拓扑 +nav.settings=设置 +overview.title=网络概览 +overview.subtitle=本地节点与数据平面实时状态 +overview.dataPlane=数据平面 +overview.localAddress=本地节点地址 +connections.title=连接 +connections.subtitle=远端链路状态与控制 +routing.title=路由 +topology.title=拓扑 +settings.title=设置 +connections.endpoint=远端端点 +connections.endpointPrompt=输入端点地址 +connections.add=添加端点 +connections.added=端点已添加 +connections.reconnectAll=全部重连 +connections.reconnecting=正在重连 +connections.showOffline=显示离线 +connections.hideOffline=隐藏离线 +connections.empty=当前没有可显示的连接 +connections.reconnect=重连 +connections.disconnect=强制断开 +connections.disconnected=连接已断开 +connections.remove=移除 +connections.removed=连接已移除 +connections.confirmTitle=确认移除连接 +connections.confirmHeader=此操作将移除匹配端点的连接 +state.online=在线 +state.offline=离线 +metric.links=链路在线数 / 总数 +metric.totalThroughput=总吞吐 +metric.totalPps=总 PPS +metric.upload=上传 +metric.download=下载 +metric.pps=PPS +metric.up=上传 +metric.down=下载 +format.bytesPerSecond=B/s +format.kilobytesPerSecond=KB/s +format.megabytesPerSecond=MB/s +format.gigabytesPerSecond=GB/s +format.milliseconds=毫秒 +copyHelp=复制地址到剪贴板 +shell.productHint=SRv6 网络控制台 +shell.workspace=工作区 +shell.awaitingGateway=等待网关连接 +shell.noData=尚未接入运行数据 +shell.footer=运行状态 · 数据由网关提供 +shell.connected=网关已连接 +shell.links=条链路 +shell.error=暂时不可用 +shell.refreshFailed=刷新失败,请稍后重试 +shell.pendingView=该视图将在后续迁移中提供 +metric.devices=在线设备 +metric.totalTransferred=累计流量 +overview.backplane=骨干面 +overview.clock=时钟 +metric.delay=延迟 +metric.ppsMax=PPS 峰值 +metric.ecnLoss=ECN / 丢包 +metric.clockTime=当前时间 +metric.frequency=频率 +metric.ppm=频偏 +format.percent=% +format.hz=Hz +format.ppm=PPM +state.unstable=不稳定 +state.accessible=连接状态 +connections.copyEndpoint=复制端点地址 +connections.copyVirtual=复制虚拟地址 +connections.copyBind=复制绑定地址 diff --git a/src/org/kne/cloud/network/klalb/KLALBMain.java b/src/org/kne/cloud/network/klalb/KLALBMain.java index bbcac9f..b12162f 100644 --- a/src/org/kne/cloud/network/klalb/KLALBMain.java +++ b/src/org/kne/cloud/network/klalb/KLALBMain.java @@ -11,23 +11,31 @@ import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.NoSuchElementException; -import java.util.Scanner; - -import org.kne.cloud.network.MultiProtocolSocketAddress; -import org.kne.cloud.network.SocketChannelListener; -import org.kne.cloud.network.ipv6.IPv6NetworkLink; -import org.kne.cloud.network.ipv6.RouteItem; -import org.kne.cloud.network.klalb.ui.KLALBStateGUI3; -import org.kne.cloud.network.klalb.ui.UIEnv; +import java.util.Scanner; + +import javax.swing.SwingUtilities; + +import javafx.scene.Scene; + +import org.kne.cloud.network.MultiProtocolSocketAddress; +import org.kne.cloud.network.SocketChannelListener; +import org.kne.cloud.network.ipv6.IPv6NetworkLink; +import org.kne.cloud.network.ipv6.RouteItem; +import org.kne.cloud.network.klalb.ui.KLALBStateGUI3; +import org.kne.cloud.network.klalb.ui.UIEnv; +import org.kne.cloud.network.klalb.ui.fx.FxOperationsConsoleShell; +import org.kne.cloud.network.klalb.ui.fx.KLALBJavaFXGateway; import org.kne.cloud.network.perf.Kperf; import org.kne.cloud.network.perf.NodeBenchmark; import org.kne.debug.TimeDebugger; import org.kne.membandboost.MembandBenchmark; import org.kne.membandboost.ObjectPoolBandwidthTest; -public class KLALBMain { - public static KLALBStateGUI3 ksg; - public static void main(String[] args) throws IOException { +public class KLALBMain { + public static KLALBStateGUI3 ksg; + private static KLALBJavaFXGateway fxGateway; + private static final Object swingGuiLock = new Object(); + public static void main(String[] args) throws IOException { try { UIEnv.inituie(); }catch(Throwable e) { @@ -46,9 +54,14 @@ public class KLALBMain { System.out.println("=".repeat(65)); System.out.println(" - IPv6 Address / End SID: "+kpcje.getKlalbController().getSelf().getAddress()); System.out.println("=".repeat(65)); - try { - if(!kpcje.getControllerConfig().isNogui()) - openGUI(kpcje); + try { + if(!kpcje.getControllerConfig().isNogui()) { + if (hasArgument(args, "--javafx")) { + openJavaFX(kpcje); + } else { + openGUI(kpcje); + } + } }catch(Throwable e) { e.printStackTrace(); } @@ -267,14 +280,46 @@ public class KLALBMain { } } - private static void openGUI(KLALBProxySystem kpcje) throws RuntimeException{ - Thread t=new Thread(()->{ - if(ksg==null) { - ksg=kpcje.getKLALBGUI(); - } - ksg.setVisible(true); - }); - t.start(); - } - -} + private static void openGUI(KLALBProxySystem kpcje) throws RuntimeException{ + Runnable showWindow = () -> { + synchronized (swingGuiLock) { + if (ksg == null) { + ksg = kpcje.getKLALBGUI(); + } + ksg.setVisible(true); + } + }; + if (SwingUtilities.isEventDispatchThread()) { + showWindow.run(); + } else { + SwingUtilities.invokeLater(showWindow); + } + } + + private static void openJavaFX(KLALBProxySystem kpcje) { + if (fxGateway == null) { + fxGateway = new KLALBJavaFXGateway(kpcje.getKlalbController()); + } + fxGateway.openStage(stage -> { + FxOperationsConsoleShell shell = new FxOperationsConsoleShell(fxGateway); + stage.setTitle(CONST.klalb); + stage.setScene(new Scene(shell.createRoot())); + stage.setOnShown(event -> shell.startRefresh()); + stage.setOnHidden(event -> shell.dispose()); + }).whenComplete((stage, error) -> { + if (error != null) { + System.err.println("Failed to start JavaFX: " + error.getMessage()); + } + }); + } + + private static boolean hasArgument(String[] args, String expected) { + for (String arg : args) { + if (expected.equals(arg)) { + return true; + } + } + return false; + } + +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/FxFormat.java b/src/org/kne/cloud/network/klalb/ui/fx/FxFormat.java new file mode 100644 index 0000000..ee8570b --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/FxFormat.java @@ -0,0 +1,18 @@ +package org.kne.cloud.network.klalb.ui.fx; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Locale; + +final class FxFormat { + private static final DecimalFormat NUMBER = new DecimalFormat("#,##0"); + private FxFormat() { } + static String number(long value) { return NUMBER.format(Math.max(0, value)); } + static String rate(long bytesPerSecond, FxText text) { double value = Math.max(0, bytesPerSecond); String[] units = { "format.bytesPerSecond", "format.kilobytesPerSecond", "format.megabytesPerSecond", "format.gigabytesPerSecond" }; int unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } return (unit == 0 ? number((long) value) : new DecimalFormat("#,##0.0").format(value)) + " " + text.get(units[unit]); } + static String delay(long nanos, FxText text) { long safe = Math.max(0, nanos); if (safe < 1_000) return safe + " ns"; if (safe < 1_000_000) return decimal(safe / 1_000.0, "#,##0.###") + " us"; double millis = safe / 1_000_000.0; String pattern = millis < 10 ? "#,##0.###" : millis < 100 ? "#,##0.##" : "#,##0.#"; return decimal(millis, pattern) + " " + text.get("format.milliseconds"); } + private static String decimal(double value, String pattern) { return new DecimalFormat(pattern, DecimalFormatSymbols.getInstance(Locale.ROOT)).format(value); } + static String bytes(long bytes, FxText text) { return rate(bytes, text).replace("/s", ""); } + static String percent(double value, FxText text) { return String.format(java.util.Locale.ROOT, "%.2f %s", Math.max(0, value) * 100, text.get("format.percent")); } + static String frequency(long value, FxText text) { return number(value) + " " + text.get("format.hz"); } + static String ppm(double value, FxText text) { return String.format(java.util.Locale.ROOT, "%.3f %s", value, text.get("format.ppm")); } +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/FxOperationsConsoleShell.java b/src/org/kne/cloud/network/klalb/ui/fx/FxOperationsConsoleShell.java new file mode 100644 index 0000000..a2464e7 --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/FxOperationsConsoleShell.java @@ -0,0 +1,364 @@ +package org.kne.cloud.network.klalb.ui.fx; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.collections.FXCollections; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Parent; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar.ButtonData; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Separator; +import javafx.scene.control.TextField; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.layout.FlowPane; + +/** Native JavaFX shell for the first migrated operations views. */ +public final class FxOperationsConsoleShell { + private static final int STATE_DOWN = 0; + private static final int STATE_UNSTABLE = 1; + private static final int STATE_UP = 2; + public enum Section { + OVERVIEW("overview", "Overview", "overview.title"), + CONNECTIONS("connections", "Connections", "connections.title"), + ROUTING("routing", "Routing", "routing.title"), + TOPOLOGY("topology", "Topology", "topology.title"), + SETTINGS("settings", "Settings", "settings.title"); + + private final String key; + private final String accessibleName; + private final String titleKey; + + Section(String key, String accessibleName, String titleKey) { + this.key = key; + this.accessibleName = accessibleName; + this.titleKey = titleKey; + } + } + + private final KLALBJavaFXPresentation presentation; + private final KLALBJavaFXGateway gateway; + private final ReadOnlyObjectWrapper
selectedSection = + new ReadOnlyObjectWrapper<>(Section.OVERVIEW); + private final BorderPane root = new BorderPane(); + private final StackPane content = new StackPane(); + private final Label pageTitle = new Label(); + private final Label connectionState = new Label(); + private final Label statusDetail = new Label(); + private final VBox navigation = new VBox(6); + private final FxText text = new FxText(); + private KLALBJavaFXRefreshCoordinator refreshCoordinator; + private boolean showOffline; + private KLALBStatusSnapshot latestStatus; + private KLALBConnectionsSnapshot latestConnections; + private StackPane connectionsView; + private TextField endpointInput; + private Button addEndpointButton; + private Button reconnectAllButton; + private Button offlineButton; + private ScrollPane connectionsScroll; + private VBox connectionRows; + private Label emptyConnections; + private final Map connectionRowViews = new LinkedHashMap<>(); + + public FxOperationsConsoleShell(KLALBJavaFXPresentation presentation) { + this(presentation, null); + } + + /** Allows a lifecycle owner to supply the bounded coordinator it already owns. */ + public FxOperationsConsoleShell(KLALBJavaFXPresentation presentation, KLALBJavaFXRefreshCoordinator coordinator) { + this.presentation = presentation; + this.gateway = null; + this.refreshCoordinator = coordinator; + build(); + } + + public FxOperationsConsoleShell(KLALBJavaFXGateway gateway) { + this.presentation = gateway; + this.gateway = gateway; + build(); + } + + /** Compatibility constructor for lifecycle wiring before the presentation is available. */ + public FxOperationsConsoleShell() { + this(null); + } + + public Parent createRoot() { return root; } + + public KLALBJavaFXPresentation getPresentation() { return presentation; } + + public ReadOnlyObjectProperty
selectedSectionProperty() { + return selectedSection.getReadOnlyProperty(); + } + + public Section getSelectedSection() { return selectedSection.get(); } + + public void showSection(Section section) { + if (section == null) return; + selectedSection.set(section); + pageTitle.setText(text.get(section.titleKey)); + refreshNavigationState(); + if (section == Section.OVERVIEW && latestStatus != null) showOverview(latestStatus); + else if (section == Section.CONNECTIONS && latestConnections != null) showConnections(latestConnections); + else showPlaceholder(section); + } + + /** Starts one bounded overview/connections request chain while this shell is visible. */ + public void startRefresh() { + if (presentation == null) return; + if (refreshCoordinator == null && gateway != null) refreshCoordinator = gateway.createRefreshCoordinator(Duration.ofSeconds(1)); + if (refreshCoordinator != null) { + refreshCoordinator.start(new KLALBJavaFXRefreshCoordinator.Listener() { + public void onOverview(KLALBStatusSnapshot snapshot) { latestStatus = snapshot; if (getSelectedSection() == Section.OVERVIEW) showOverview(snapshot); updateHeader(snapshot); } + public void onConnections(KLALBConnectionsSnapshot snapshot) { latestConnections = snapshot; if (getSelectedSection() == Section.CONNECTIONS) showConnections(snapshot); } + public void onError(Throwable error) { showError(error); } + }); + } + } + + /** Parent lifecycle must call this when the stage is hidden or disposed. */ + public void dispose() { + if (refreshCoordinator != null) { refreshCoordinator.close(); refreshCoordinator = null; } + } + + private void build() { + root.getStyleClass().add("fx-console"); + java.net.URL stylesheet = getClass().getResource("klalb-operations.css"); + if (stylesheet != null) root.getStylesheets().add(stylesheet.toExternalForm()); + root.setTop(buildHeader()); root.setLeft(buildSidebar()); root.setCenter(content); root.setBottom(buildFooter()); + root.setMinSize(760, 500); showSection(Section.OVERVIEW); + } + + private Parent buildHeader() { + Label product = new Label("KLALB"); product.getStyleClass().add("product-mark"); + Label hint = new Label(text.get("shell.productHint")); hint.getStyleClass().add("product-hint"); + HBox identity = new HBox(12, product, new Separator(javafx.geometry.Orientation.VERTICAL), hint); + identity.setAlignment(Pos.CENTER_LEFT); + pageTitle.getStyleClass().add("page-title"); + Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); + Label dot = new Label("●"); dot.getStyleClass().add("status-dot"); + connectionState.getStyleClass().add("status-label"); statusDetail.getStyleClass().add("status-detail"); + HBox status = new HBox(8, dot, connectionState, statusDetail); status.setAlignment(Pos.CENTER_RIGHT); + HBox header = new HBox(24, identity, pageTitle, spacer, status); header.getStyleClass().add("topbar"); + header.setAlignment(Pos.CENTER_LEFT); header.setPadding(new Insets(18, 28, 18, 28)); + connectionState.setText(text.get("shell.awaitingGateway")); statusDetail.setText(text.get("shell.noData")); + return header; + } + + private Parent buildSidebar() { + navigation.getStyleClass().add("navigation"); + for (Section section : Section.values()) { + Button item = new Button(text.get("nav." + section.key)); item.setMaxWidth(Double.MAX_VALUE); item.setMinHeight(42); + item.setMnemonicParsing(false); item.setAccessibleText(section.accessibleName); item.setAccessibleHelp(text.get(section.titleKey)); + item.setContentDisplay(ContentDisplay.LEFT); item.setOnAction(event -> showSection(section)); item.getProperties().put("section", section); + navigation.getChildren().add(item); + } + Label caption = new Label(text.get("shell.workspace")); caption.getStyleClass().add("nav-caption"); + VBox sidebar = new VBox(12, caption, navigation); sidebar.getStyleClass().add("sidebar"); sidebar.setPadding(new Insets(25, 16, 20, 16)); sidebar.setPrefWidth(188); + return sidebar; + } + + private Parent buildFooter() { + Label footer = new Label(text.get("shell.footer")); footer.getStyleClass().add("footer-label"); + HBox bar = new HBox(footer); bar.getStyleClass().add("footer"); bar.setPadding(new Insets(9, 28, 10, 28)); + return bar; + } + + private void showOverview(KLALBStatusSnapshot s) { + VBox body = pageBody("overview.title", "overview.subtitle"); + FlowPane stats = new FlowPane(12, 12, metric(text.get("metric.links"), s.getActiveLinkCount() + " / " + s.getLinkCount()), + metric(text.get("metric.devices"), FxFormat.number(s.getOnlineDeviceCount())), + metric(text.get("metric.totalThroughput"), FxFormat.rate(s.getUploadBytesPerSecond() + s.getDownloadBytesPerSecond(), text)), + metric(text.get("metric.totalPps"), FxFormat.number(s.getUploadPacketsPerSecond() + s.getDownloadPacketsPerSecond()))); + stats.getStyleClass().add("metric-row"); + VBox traffic = panel(text.get("overview.dataPlane"), + line(text.get("metric.upload"), FxFormat.rate(s.getDataUploadBytesPerSecond(), text) + " · " + FxFormat.number(s.getDataUploadPacketsPerSecond()) + " " + text.get("metric.pps")), + line(text.get("metric.download"), FxFormat.rate(s.getDataDownloadBytesPerSecond(), text) + " · " + FxFormat.number(s.getDataDownloadPacketsPerSecond()) + " " + text.get("metric.pps")), + line(text.get("metric.totalTransferred"), FxFormat.bytes(s.getDataUploadBytes() + s.getDataDownloadBytes(), text))); + VBox backplane = panel(text.get("overview.backplane"), + line(text.get("metric.delay"), FxFormat.delay(s.getBackplaneDelayNanos(), text)), + line(text.get("metric.pps"), FxFormat.number(s.getBackplanePacketsPerSecond())), + line(text.get("metric.ppsMax"), FxFormat.number(s.getBackplanePacketsMax())), + line(text.get("metric.ecnLoss"), FxFormat.percent(s.getBackplaneEcnRate(), text) + " / " + FxFormat.percent(s.getBackplaneLossRate(), text))); + FlowPane health = new FlowPane(12, 12, traffic, backplane); health.getStyleClass().add("overview-panels"); + VBox clock = panel(text.get("overview.clock"), + line(text.get("metric.clockTime"), nullToDash(s.getClockTime())), + line(text.get("metric.frequency"), FxFormat.frequency(s.getClockFrequency(), text)), + line(text.get("metric.ppm"), FxFormat.ppm(s.getClockFrequencyOffsetPpm(), text))); + HBox node = new HBox(10, new Label(text.get("overview.localAddress")), new Label(s.getNodeAddress()), copyButton(s.getNodeAddress())); + node.getStyleClass().add("address-row"); node.setAlignment(Pos.CENTER_LEFT); + body.getChildren().addAll(stats, health, clock, node); content.setPadding(new Insets(26, 30, 30, 30)); content.getChildren().setAll(wrap(body)); + } + + private void showConnections(KLALBConnectionsSnapshot snapshot) { + ensureConnectionsView(); + applyConnectionsSnapshot(snapshot); + content.setPadding(new Insets(26, 30, 30, 30)); content.getChildren().setAll(connectionsView); + } + + private void ensureConnectionsView() { + if (connectionsView != null) return; + VBox body = pageBody("connections.title", "connections.subtitle"); + HBox actions = new HBox(8); actions.getStyleClass().add("action-row"); + endpointInput = new TextField(); endpointInput.setPromptText(text.get("connections.endpointPrompt")); endpointInput.setAccessibleText(text.get("connections.endpoint")); HBox.setHgrow(endpointInput, Priority.ALWAYS); + addEndpointButton = action(text.get("connections.add")); + addEndpointButton.setOnAction(e -> runAddAction(presentation.addEndpoint(endpointInput.getText().trim()), addEndpointButton)); + endpointInput.setOnAction(e -> addEndpointButton.fire()); + reconnectAllButton = action(text.get("connections.reconnectAll")); + reconnectAllButton.setOnAction(e -> runAction(presentation.reconnectNow(), reconnectAllButton, text.get("connections.reconnecting"))); + offlineButton = action(showOffline ? text.get("connections.hideOffline") : text.get("connections.showOffline")); + offlineButton.setOnAction(e -> { showOffline = !showOffline; updateOfflineButton(); applyConnectionsSnapshot(latestConnections); }); + actions.getChildren().addAll(endpointInput, addEndpointButton, reconnectAllButton, offlineButton); + body.getChildren().add(actions); + connectionRows = new VBox(8); connectionRows.getStyleClass().add("connection-list"); + connectionsScroll = new ScrollPane(connectionRows); connectionsScroll.setFitToWidth(true); connectionsScroll.getStyleClass().add("connection-scroll"); VBox.setVgrow(connectionsScroll, Priority.ALWAYS); body.getChildren().add(connectionsScroll); + connectionsView = wrap(body); + } + + private void applyConnectionsSnapshot(KLALBConnectionsSnapshot snapshot) { + if (snapshot == null) return; + List visibleRows = new ArrayList<>(); + for (KLALBConnectionSnapshot row : snapshot.getRows()) { + String token = row.getIdentityToken(); + ConnectionRowView view = connectionRowViews.get(token); + if (view == null) { + view = new ConnectionRowView(row); + connectionRowViews.put(token, view); + } else { + view.update(row); + } + if (showOffline || row.getState() != STATE_DOWN) visibleRows.add(view); + } + connectionRowViews.keySet().retainAll(tokens(snapshot)); + emptyConnections = emptyConnections == null ? empty(text.get("connections.empty")) : emptyConnections; + reconcileConnectionRows(visibleRows); + } + + private void reconcileConnectionRows(List visibleRows) { + if (visibleRows.isEmpty()) { + for (int i = connectionRows.getChildren().size() - 1; i >= 0; i--) { + if (connectionRows.getChildren().get(i) != emptyConnections) connectionRows.getChildren().remove(i); + } + if (!connectionRows.getChildren().contains(emptyConnections)) connectionRows.getChildren().add(emptyConnections); + return; + } + + for (int i = connectionRows.getChildren().size() - 1; i >= 0; i--) { + if (!containsRoot(visibleRows, connectionRows.getChildren().get(i))) connectionRows.getChildren().remove(i); + } + for (ConnectionRowView row : visibleRows) { + if (!connectionRows.getChildren().contains(row.root)) connectionRows.getChildren().add(row.root); + } + FXCollections.sort(connectionRows.getChildren(), (left, right) -> Integer.compare( + indexOfRoot(visibleRows, left), indexOfRoot(visibleRows, right))); + } + + private boolean containsRoot(List rows, javafx.scene.Node node) { + for (ConnectionRowView row : rows) if (row.root == node) return true; + return false; + } + + private int indexOfRoot(List rows, javafx.scene.Node node) { + for (int i = 0; i < rows.size(); i++) if (rows.get(i).root == node) return i; + return rows.size(); + } + + private List tokens(KLALBConnectionsSnapshot snapshot) { + List tokens = new ArrayList<>(); + for (KLALBConnectionSnapshot row : snapshot.getRows()) tokens.add(row.getIdentityToken()); + return tokens; + } + + private void updateOfflineButton() { + if (offlineButton != null) offlineButton.setText(text.get(showOffline ? "connections.hideOffline" : "connections.showOffline")); + } + + private final class ConnectionRowView { + private String identityToken; + private KLALBConnectionSnapshot currentSnapshot; + private final HBox root; + private final Label state = new Label(); + private final Label name = new Label(); + private final Label address = new Label(); + private final Label metrics = new Label(); + private final Button copyEndpoint; + private final Button copyVirtual; + private final Button copyBind; + + private ConnectionRowView(KLALBConnectionSnapshot row) { + state.getStyleClass().add("connection-state"); name.getStyleClass().add("row-name"); address.getStyleClass().add("row-address"); metrics.getStyleClass().add("row-metrics"); + VBox info = new VBox(5, new HBox(9, state, name), address, metrics); HBox.setHgrow(info, Priority.ALWAYS); + copyEndpoint = copyButton(row.getSocketAddress(), "connections.copyEndpoint"); + copyVirtual = copyButton(row.getRemoteAddress(), "connections.copyVirtual"); + copyBind = copyButton(row.getBindAddress(), "connections.copyBind"); + HBox copies = new HBox(5, copyEndpoint, copyVirtual, copyBind); + Button reconnect = action(text.get("connections.reconnect")); reconnect.setOnAction(e -> runAction(presentation.reconnectLink(identityToken), reconnect, text.get("connections.reconnecting"))); + Button disconnect = action(text.get("connections.disconnect")); disconnect.setOnAction(e -> runAction(presentation.forceDisconnect(identityToken), disconnect, text.get("connections.disconnected"))); + Button remove = action(text.get("connections.remove")); remove.setOnAction(e -> confirmRemove(currentSnapshot, remove)); + VBox buttons = new VBox(5, copies, new HBox(5, reconnect, disconnect, remove)); buttons.getStyleClass().add("row-actions"); + root = new HBox(14, info, buttons); root.getStyleClass().add("connection-row"); root.setAlignment(Pos.CENTER_LEFT); + update(row); + } + + private void update(KLALBConnectionSnapshot row) { + currentSnapshot = row; + identityToken = row.getIdentityToken(); + boolean unstable = row.getState() == STATE_UNSTABLE; + boolean online = row.getState() == STATE_UP; + state.setText(unstable ? text.get("state.unstable") : online ? text.get("state.online") : text.get("state.offline")); + state.getStyleClass().removeAll("state-unstable", "state-online", "state-offline"); + state.getStyleClass().add(unstable ? "state-unstable" : online ? "state-online" : "state-offline"); + state.setAccessibleText(text.get("state.accessible") + " " + state.getText()); + name.setText(row.getName() == null ? row.getConnectionId() : row.getName()); + address.setText(nullToDash(row.getRemoteAddress())); + metrics.setText(FxFormat.rate(row.getUploadBytesPerSecond(), text) + " " + text.get("metric.up") + " " + FxFormat.rate(row.getDownloadBytesPerSecond(), text) + " " + text.get("metric.down") + " " + FxFormat.delay(row.getUploadDelayNanos(), text) + " / " + FxFormat.delay(row.getDownloadDelayNanos(), text)); + copyEndpoint.setOnAction(e -> copy(currentSnapshot.getSocketAddress())); + copyVirtual.setOnAction(e -> copy(currentSnapshot.getRemoteAddress())); + copyBind.setOnAction(e -> copy(currentSnapshot.getBindAddress())); + } + } + + private void confirmRemove(KLALBConnectionSnapshot row, Button source) { + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); alert.setTitle(text.get("connections.confirmTitle")); alert.setHeaderText(text.get("connections.confirmHeader")); alert.setContentText(nullToDash(row.getSocketAddress())); + ButtonType yes = new ButtonType(text.get("connections.remove"), ButtonData.OK_DONE); alert.getButtonTypes().setAll(yes, ButtonType.CANCEL); + Optional result = alert.showAndWait(); if (result.isPresent() && result.get() == yes) runAction(presentation.removeLink(row.getIdentityToken()), source, text.get("connections.removed")); + } + + private VBox pageBody(String titleKey, String subtitleKey) { Label title = new Label(text.get(titleKey)); title.getStyleClass().add("view-title"); Label sub = new Label(text.get(subtitleKey)); sub.getStyleClass().add("view-subtitle"); VBox body = new VBox(18, title, sub); body.getStyleClass().add("page-body"); return body; } + private VBox metric(String label, String value) { Label l = new Label(label); l.getStyleClass().add("metric-label"); Label v = new Label(value); v.getStyleClass().add("metric-value"); VBox box = new VBox(8, l, v); box.getStyleClass().add("metric"); HBox.setHgrow(box, Priority.ALWAYS); return box; } + private VBox panel(String title, Parent... children) { Label h = new Label(title); h.getStyleClass().add("panel-title"); VBox p = new VBox(12); p.getStyleClass().add("data-panel"); p.getChildren().add(h); p.getChildren().addAll(children); return p; } + private HBox line(String label, String value) { Label l = new Label(label); l.getStyleClass().add("line-label"); Label v = new Label(value); v.getStyleClass().add("line-value"); HBox row = new HBox(l, v); HBox.setHgrow(v, Priority.ALWAYS); row.getStyleClass().add("data-line"); return row; } + private Button action(String label) { Button b = new Button(label); b.setMnemonicParsing(false); return b; } + private Button copyButton(String value) { return copyButton(value, "copyHelp"); } + private Button copyButton(String value, String helpKey) { Button b = action(text.get("copy")); b.setAccessibleHelp(text.get(helpKey)); b.setOnAction(e -> copy(value)); return b; } + private void copy(String value) { if (value != null && !value.isEmpty()) { ClipboardContent c = new ClipboardContent(); c.putString(value); Clipboard.getSystemClipboard().setContent(c); } } + private StackPane wrap(Parent child) { StackPane pane = new StackPane(child); pane.setAlignment(Pos.TOP_LEFT); return pane; } + private Label empty(String value) { Label l = new Label(value); l.getStyleClass().add("empty-message"); return l; } + private void showPlaceholder(Section section) { content.getChildren().setAll(wrap(pageBody(section.titleKey, "shell.pendingView"))); } + private void updateHeader(KLALBStatusSnapshot s) { connectionState.setText(text.get("shell.connected")); statusDetail.setText(s.getActiveLinkCount() + " / " + s.getLinkCount() + " " + text.get("shell.links")); } + private void showError(Throwable error) { connectionState.setText(text.get("shell.error")); statusDetail.setText(text.get("shell.refreshFailed")); if (getSelectedSection() == Section.OVERVIEW) content.getChildren().setAll(wrap(pageBody("shell.error", "shell.refreshFailed"))); } + private void runAction(java.util.concurrent.CompletableFuture future, Button source, String success) { if (future == null) return; source.setDisable(true); future.whenComplete((v, e) -> { source.setDisable(false); if (e != null) showError(e); else statusDetail.setText(success); }); } + private void runAddAction(java.util.concurrent.CompletableFuture future, Button source) { if (future == null) return; source.setDisable(true); future.whenComplete((count, e) -> { source.setDisable(false); if (e != null) showError(e); else statusDetail.setText(text.get("connections.added") + " (" + count + ")"); }); } + private void refreshNavigationState() { for (javafx.scene.Node node : navigation.getChildren()) { Button b = (Button) node; b.pseudoClassStateChanged(javafx.css.PseudoClass.getPseudoClass("selected"), b.getProperties().get("section") == selectedSection.get()); } } + private static String nullToDash(String value) { return value == null || value.isEmpty() ? "-" : value; } +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/FxText.java b/src/org/kne/cloud/network/klalb/ui/fx/FxText.java new file mode 100644 index 0000000..9ea0dd0 --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/FxText.java @@ -0,0 +1,10 @@ +package org.kne.cloud.network.klalb.ui.fx; + +import java.util.ResourceBundle; +import org.kne.cloud.network.klalb.ui.UIEnv; + +/** Bundle bridge for JavaFX copy; follows the application's active bundle. */ +final class FxText { + private final ResourceBundle bundle = UIEnv.getRsb(); + String get(String key) { return bundle.containsKey(key) ? bundle.getString(key) : key; } +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/KLALBConnectionSnapshot.java b/src/org/kne/cloud/network/klalb/ui/fx/KLALBConnectionSnapshot.java new file mode 100644 index 0000000..7c86916 --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/KLALBConnectionSnapshot.java @@ -0,0 +1,147 @@ +package org.kne.cloud.network.klalb.ui.fx; + +import org.kne.cloud.network.klalb.KLALBRemoteLink; +import org.kne.cloud.network.ipv6.IPv6AddressGroup; +import org.kne.cloud.network.monitor.LinkStatus; +import org.kne.cloud.network.monitor.SpeedAndTrafficAndDelayMonitorDataImpl; + +/** + * Immutable presentation data for one remote connection. + */ +public final class KLALBConnectionSnapshot { + private final String identityToken; + private final String connectionId; + private final String name; + private final String socketAddress; + private final String bindAddress; + private final String remoteAddress; + private final int state; + private final String stateLabel; + private final boolean up; + private final long uploadBytesPerSecond; + private final long downloadBytesPerSecond; + private final long uploadPacketsPerSecond; + private final long downloadPacketsPerSecond; + private final long uploadDelayNanos; + private final long downloadDelayNanos; + private final long queueUsed; + + private KLALBConnectionSnapshot(String identityToken, String connectionId, String name, String socketAddress, + String bindAddress, String remoteAddress, int state, String stateLabel, boolean up, + long uploadBytesPerSecond, long downloadBytesPerSecond, + long uploadPacketsPerSecond, long downloadPacketsPerSecond, long uploadDelayNanos, + long downloadDelayNanos, long queueUsed) { + this.identityToken = identityToken; + this.connectionId = connectionId; + this.name = name; + this.socketAddress = socketAddress; + this.bindAddress = bindAddress; + this.remoteAddress = remoteAddress; + this.state = state; + this.stateLabel = stateLabel; + this.up = up; + this.uploadBytesPerSecond = uploadBytesPerSecond; + this.downloadBytesPerSecond = downloadBytesPerSecond; + this.uploadPacketsPerSecond = uploadPacketsPerSecond; + this.downloadPacketsPerSecond = downloadPacketsPerSecond; + this.uploadDelayNanos = uploadDelayNanos; + this.downloadDelayNanos = downloadDelayNanos; + this.queueUsed = queueUsed; + } + + static KLALBConnectionSnapshot capture(KLALBRemoteLink link, String identityToken) { + SpeedAndTrafficAndDelayMonitorDataImpl monitor = link.getMonitor(); + String socketAddress = text(link.getSocketAddress()); + String name = link.getName(); + IPv6AddressGroup remoteVaddr = link.getRemoteVaddr(); + return new KLALBConnectionSnapshot( + identityToken, + connectionId(socketAddress), + name, + socketAddress, + text(link.getBindAddress()), + remoteVaddr == null ? null : remoteVaddr.getAddress().toString(), + link.getState(), + LinkStatus.stateToString(link.getState()), + link.isUp(), + monitor.getOutSpeed(), + monitor.getInSpeed(), + monitor.getOutPPS(), + monitor.getInPPS(), + monitor.getOutDelay(), + monitor.getInDelay(), + link.getQueueUsed()); + } + + private static String connectionId(String socketAddress) { + return socketAddress == null ? "" : socketAddress; + } + + private static String text(Object value) { + return value == null ? null : value.toString(); + } + + public String getConnectionId() { + return connectionId; + } + + public String getIdentityToken() { + return identityToken; + } + + public String getName() { + return name; + } + + public String getSocketAddress() { + return socketAddress; + } + + public String getBindAddress() { + return bindAddress; + } + + public String getRemoteAddress() { + return remoteAddress; + } + + public int getState() { + return state; + } + + public String getStateLabel() { + return stateLabel; + } + + public boolean isUp() { + return up; + } + + public long getUploadBytesPerSecond() { + return uploadBytesPerSecond; + } + + public long getDownloadBytesPerSecond() { + return downloadBytesPerSecond; + } + + public long getUploadPacketsPerSecond() { + return uploadPacketsPerSecond; + } + + public long getDownloadPacketsPerSecond() { + return downloadPacketsPerSecond; + } + + public long getUploadDelayNanos() { + return uploadDelayNanos; + } + + public long getDownloadDelayNanos() { + return downloadDelayNanos; + } + + public long getQueueUsed() { + return queueUsed; + } +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/KLALBConnectionsSnapshot.java b/src/org/kne/cloud/network/klalb/ui/fx/KLALBConnectionsSnapshot.java new file mode 100644 index 0000000..ca16bca --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/KLALBConnectionsSnapshot.java @@ -0,0 +1,52 @@ +package org.kne.cloud.network.klalb.ui.fx; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.kne.cloud.network.ipv6.IPv6NetworkLink; +import org.kne.cloud.network.klalb.KLALBController; +import org.kne.cloud.network.klalb.KLALBRemoteLink; + +/** + * Immutable snapshot of the current remote connection rows. + */ +public final class KLALBConnectionsSnapshot { + private final List rows; + + private KLALBConnectionsSnapshot(List rows) { + this.rows = Collections.unmodifiableList(new ArrayList<>(rows)); + } + + static KLALBConnectionsSnapshot capture(KLALBController controller, ConnectionIdentity identity) { + return capture(controller, controller.getLines(), identity); + } + + static KLALBConnectionsSnapshot capture(KLALBController controller, + List links, ConnectionIdentity identity) { + List rows = new ArrayList<>(); + for (IPv6NetworkLink link : links) { + if (link instanceof KLALBRemoteLink) { + KLALBRemoteLink remoteLink = (KLALBRemoteLink) link; + rows.add(KLALBConnectionSnapshot.capture(remoteLink, identity.tokenFor(remoteLink))); + } + } + return new KLALBConnectionsSnapshot(rows); + } + + static KLALBConnectionsSnapshot of(List rows) { + return new KLALBConnectionsSnapshot(rows); + } + + public List getRows() { + return rows; + } + + public int getConnectionCount() { + return rows.size(); + } + + interface ConnectionIdentity { + String tokenFor(KLALBRemoteLink link); + } +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXApplication.java b/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXApplication.java new file mode 100644 index 0000000..3d35f1d --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXApplication.java @@ -0,0 +1,100 @@ +package org.kne.cloud.network.klalb.ui.fx; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.BooleanSupplier; + +import javafx.application.Application; +import javafx.application.Platform; +import javafx.stage.Stage; + +/** + * Owns the JavaFX toolkit lifecycle without owning any application view. + */ +public final class KLALBJavaFXApplication extends Application { + private static final AtomicBoolean launchRequested = new AtomicBoolean(); + private static final CompletableFuture ready = new CompletableFuture<>(); + + /** + * Starts JavaFX asynchronously. The JavaFX toolkit is process-global and + * can only be launched once, so every caller receives the same future. + */ + public static CompletableFuture startAsync() { + if (launchRequested.compareAndSet(false, true)) { + Thread launcher = new Thread(() -> { + try { + Application.launch(KLALBJavaFXApplication.class); + } catch (Throwable error) { + ready.completeExceptionally(error); + } + }, "KLALB JavaFX launcher"); + launcher.setDaemon(true); + launcher.start(); + } + return ready; + } + + @Override + public void start(Stage primaryStage) { + // The primary stage is intentionally not shown. Views open their own + // stages through KLALBJavaFXGateway when a desktop workflow is selected. + Platform.setImplicitExit(false); + ready.complete(this); + } + + CompletableFuture openStage(Consumer initializer) { + return openStage(() -> true, initializer); + } + + CompletableFuture openStage(BooleanSupplier allowed, Consumer initializer) { + Objects.requireNonNull(initializer, "initializer"); + CompletableFuture result = new CompletableFuture<>(); + try { + Platform.runLater(() -> { + try { + if (!allowed.getAsBoolean()) { + result.completeExceptionally(new IllegalStateException("JavaFX gateway is closed")); + return; + } + Stage stage = new Stage(); + initializer.accept(stage); + stage.show(); + result.complete(stage); + } catch (Throwable error) { + result.completeExceptionally(error); + } + }); + } catch (Throwable error) { + result.completeExceptionally(error); + } + return result; + } + + CompletableFuture runOnFx(Runnable action) { + return runOnFx(() -> true, action); + } + + CompletableFuture runOnFx(BooleanSupplier allowed, Runnable action) { + Objects.requireNonNull(action, "action"); + CompletableFuture result = new CompletableFuture<>(); + try { + Platform.runLater(() -> { + try { + if (!allowed.getAsBoolean()) { + result.completeExceptionally(new IllegalStateException("JavaFX gateway is closed")); + return; + } + action.run(); + result.complete(null); + } catch (Throwable error) { + result.completeExceptionally(error); + } + }); + } catch (Throwable error) { + result.completeExceptionally(error); + } + return result; + } +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXGateway.java b/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXGateway.java new file mode 100644 index 0000000..07b4d6a --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXGateway.java @@ -0,0 +1,332 @@ +package org.kne.cloud.network.klalb.ui.fx; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import javafx.application.Platform; +import javafx.stage.Stage; + +import org.kne.cloud.network.ipv6.IPv6NetworkLink; +import org.kne.cloud.network.klalb.KLALBController; +import org.kne.cloud.network.klalb.KLALBRemoteLink; +import org.kne.cloud.network.MultiProtocolSocketAddress; + +/** + * Typed presentation boundary for JavaFX view models. + * + * Controller snapshots are captured on a dedicated background executor and + * their futures are completed on the JavaFX application thread. Stage setup + * and scene-graph work are dispatched to that same JavaFX thread. + */ +public final class KLALBJavaFXGateway implements AutoCloseable, KLALBJavaFXPresentation { + private final KLALBController controller; + private final ExecutorService controllerExecutor; + private final Set> pendingWork = ConcurrentHashMap.newKeySet(); + private final Set refreshCoordinators = ConcurrentHashMap.newKeySet(); + private final IdentityHashMap linkTokens = new IdentityHashMap<>(); + private final Map liveLinksByToken = new HashMap<>(); + private final Object lifecycleLock = new Object(); + private final AtomicBoolean closed = new AtomicBoolean(); + + public KLALBJavaFXGateway(KLALBController controller) { + this.controller = Objects.requireNonNull(controller, "controller"); + this.controllerExecutor = Executors.newSingleThreadExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "KLALB JavaFX controller"); + thread.setDaemon(true); + return thread; + } + }); + } + + public CompletableFuture startAsync() { + return KLALBJavaFXApplication.startAsync(); + } + + @Override + public CompletableFuture requestStatusSnapshot() { + return requestOverviewSnapshot(); + } + + @Override + public CompletableFuture requestOverviewSnapshot() { + return submitController(KLALBStatusSnapshot::capture); + } + + @Override + public CompletableFuture requestConnectionsSnapshot() { + return submitController(this::captureConnections); + } + + @Override + public CompletableFuture requestOverviewAndConnectionsSnapshot() { + return submitController(this::captureOverviewAndConnections); + } + + @Override + public CompletableFuture addEndpoint(String endpoint) { + return submitController(currentController -> { + int added = currentController.addRemoteLines(new MultiProtocolSocketAddress(endpoint)).size(); + if (added == 0) { + throw new IllegalStateException("No links were added for endpoint: " + endpoint); + } + return added; + }); + } + + @Override + public CompletableFuture reconnectNow() { + return submitController(currentController -> { + currentController.reconnectImmediately(); + return null; + }); + } + + @Override + public CompletableFuture reconnectLink(String identityToken) { + Objects.requireNonNull(identityToken, "identityToken"); + return submitController(currentController -> { + KLALBRemoteLink remoteLink = findLiveLink(currentController, identityToken); + if (remoteLink == null) { + throw new IllegalStateException("Connection is no longer available"); + } + remoteLink.reconnectImmediately(); + return null; + }); + } + + @Override + public CompletableFuture forceDisconnect(String identityToken) { + Objects.requireNonNull(identityToken, "identityToken"); + return submitController(currentController -> { + KLALBRemoteLink remoteLink = findLiveLink(currentController, identityToken); + if (remoteLink == null) { + throw new IllegalStateException("Connection is no longer available"); + } + remoteLink.dislink(); + return null; + }); + } + + @Override + public CompletableFuture removeLink(String identityToken) { + Objects.requireNonNull(identityToken, "identityToken"); + return submitController(currentController -> { + KLALBRemoteLink target = findLiveLink(currentController, identityToken); + if (target == null) { + throw new IllegalStateException("Connection is no longer available"); + } + MultiProtocolSocketAddress socketAddress = target.getSocketAddress(); + if (socketAddress == null) { + target.close(); + } else if (currentController.removeRemoteLines(socketAddress).isEmpty()) { + throw new IllegalStateException("No matching link found"); + } + return null; + }); + } + + public KLALBJavaFXRefreshCoordinator createRefreshCoordinator(Duration interval) { + synchronized (lifecycleLock) { + if (closed.get()) { + throw new IllegalStateException("JavaFX gateway is closed"); + } + KLALBJavaFXRefreshCoordinator coordinator = + new KLALBJavaFXRefreshCoordinator(this, interval); + refreshCoordinators.add(coordinator); + return coordinator; + } + } + + /** + * Opens a stage after JavaFX is ready. The initializer may configure only + * JavaFX objects; controller or network reads belong in request methods. + */ + public CompletableFuture openStage(Consumer initializer) { + Objects.requireNonNull(initializer, "initializer"); + if (closed.get()) { + return failedFuture(closedException()); + } + return startAsync().thenCompose(application -> application.openStage( + () -> !closed.get(), initializer)); + } + + /** + * Schedules scene-graph work on the JavaFX application thread. + */ + public CompletableFuture runOnFx(Runnable action) { + Objects.requireNonNull(action, "action"); + if (closed.get()) { + return failedFuture(closedException()); + } + return startAsync().thenCompose(application -> application.runOnFx( + () -> !closed.get(), action)); + } + + public boolean isFxApplicationThread() { + return Platform.isFxApplicationThread(); + } + + public boolean isClosed() { + return closed.get(); + } + + @Override + public void close() { + Set> workToCancel; + Set coordinatorsToClose; + synchronized (lifecycleLock) { + if (!closed.compareAndSet(false, true)) { + return; + } + controllerExecutor.shutdownNow(); + workToCancel = Set.copyOf(pendingWork); + pendingWork.clear(); + coordinatorsToClose = Set.copyOf(refreshCoordinators); + refreshCoordinators.clear(); + } + for (KLALBJavaFXRefreshCoordinator coordinator : coordinatorsToClose) { + coordinator.closeFromGateway(); + } + for (CompletableFuture work : workToCancel) { + completeOnFx(work, null, closedException()); + } + } + + void unregisterRefreshCoordinator(KLALBJavaFXRefreshCoordinator coordinator) { + refreshCoordinators.remove(coordinator); + } + + private CompletableFuture submitController(ControllerOperation operation) { + Objects.requireNonNull(operation, "operation"); + CompletableFuture result = new CompletableFuture<>(); + result.whenComplete((value, error) -> pendingWork.remove(result)); + synchronized (lifecycleLock) { + if (closed.get()) { + result.completeExceptionally(closedException()); + return result; + } + pendingWork.add(result); + startAsync(); + try { + controllerExecutor.execute(() -> { + if (closed.get()) { + completeOnFx(result, null, closedException()); + return; + } + try { + completeOnFx(result, operation.run(controller), null); + } catch (Throwable error) { + completeOnFx(result, null, error); + } + }); + } catch (Throwable error) { + completeOnFx(result, null, error); + } + } + return result; + } + + private KLALBConnectionsSnapshot captureConnections(KLALBController currentController) { + return captureConnections(currentController, new ArrayList<>(currentController.getLines())); + } + + private KLALBConnectionsSnapshot captureConnections(KLALBController currentController, + List links) { + IdentityHashMap currentTokens = new IdentityHashMap<>(); + Map currentLiveLinks = new HashMap<>(); + KLALBConnectionsSnapshot connections = KLALBConnectionsSnapshot.capture(currentController, links, + remoteLink -> { + String token = linkTokens.get(remoteLink); + if (token == null) { + token = UUID.randomUUID().toString(); + } + currentTokens.put(remoteLink, token); + currentLiveLinks.put(token, remoteLink); + return token; + }); + linkTokens.clear(); + linkTokens.putAll(currentTokens); + liveLinksByToken.clear(); + liveLinksByToken.putAll(currentLiveLinks); + return connections; + } + + private KLALBOverviewConnectionsSnapshot captureOverviewAndConnections(KLALBController currentController) { + List links = new ArrayList<>(currentController.getLines()); + KLALBConnectionsSnapshot connections = captureConnections(currentController, links); + return KLALBOverviewConnectionsSnapshot.of( + KLALBStatusSnapshot.capture(currentController, connections), connections); + } + + private KLALBRemoteLink findLiveLink(KLALBController currentController, String identityToken) { + KLALBRemoteLink remoteLink = liveLinksByToken.get(identityToken); + if (remoteLink != null && !remoteLink.isClosed() && currentController.getLines().contains(remoteLink)) { + return remoteLink; + } + return null; + } + + private void completeOnFx(CompletableFuture result, T value, Throwable error) { + if (result.isDone()) { + pendingWork.remove(result); + return; + } + KLALBJavaFXApplication.startAsync().whenComplete((application, startError) -> { + if (startError != null) { + result.completeExceptionally(startError); + pendingWork.remove(result); + return; + } + application.runOnFx(() -> { + synchronized (lifecycleLock) { + if (result.isDone()) { + return; + } + if (closed.get()) { + result.completeExceptionally(closedException()); + } else if (error == null) { + result.complete(value); + } else { + result.completeExceptionally(error); + } + } + }).whenComplete((ignored, dispatchError) -> { + if (dispatchError != null) { + result.completeExceptionally(dispatchError); + pendingWork.remove(result); + } + }); + }); + } + + private static CompletableFuture failedFuture(Throwable error) { + CompletableFuture result = new CompletableFuture<>(); + result.completeExceptionally(error); + return result; + } + + private static IllegalStateException closedException() { + return new IllegalStateException("JavaFX gateway is closed"); + } + + @FunctionalInterface + private interface ControllerOperation { + T run(KLALBController controller) throws Exception; + } +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXPresentation.java b/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXPresentation.java new file mode 100644 index 0000000..1e374b1 --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXPresentation.java @@ -0,0 +1,31 @@ +package org.kne.cloud.network.klalb.ui.fx; + +import java.util.concurrent.CompletableFuture; + +/** + * Typed presentation boundary exposed to JavaFX view models. + */ +public interface KLALBJavaFXPresentation { + /** + * Requests a controller snapshot. Completion handlers are dispatched on the + * JavaFX application thread by the gateway. + */ + CompletableFuture requestStatusSnapshot(); + + CompletableFuture requestOverviewSnapshot(); + + CompletableFuture requestConnectionsSnapshot(); + + CompletableFuture requestOverviewAndConnectionsSnapshot(); + + CompletableFuture addEndpoint(String endpoint); + + CompletableFuture reconnectNow(); + + CompletableFuture reconnectLink(String identityToken); + + CompletableFuture forceDisconnect(String identityToken); + + /** Resolves one row token, then removes every live link at its socket endpoint. */ + CompletableFuture removeLink(String identityToken); +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXRefreshCoordinator.java b/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXRefreshCoordinator.java new file mode 100644 index 0000000..8f03c79 --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/KLALBJavaFXRefreshCoordinator.java @@ -0,0 +1,133 @@ +package org.kne.cloud.network.klalb.ui.fx; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Bounded background refresh for JavaFX presentation snapshots. + * + * At most one overview/connections request chain and one FX delivery are in + * flight at a time. + */ +public final class KLALBJavaFXRefreshCoordinator implements AutoCloseable { + private final KLALBJavaFXGateway gateway; + private final Duration interval; + private final ScheduledExecutorService scheduler; + private final AtomicBoolean running = new AtomicBoolean(); + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicReference> inFlight = new AtomicReference<>(); + private volatile ScheduledFuture ticker; + private volatile Listener listener; + + KLALBJavaFXRefreshCoordinator(KLALBJavaFXGateway gateway, Duration interval) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.interval = validateInterval(interval); + this.scheduler = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "KLALB JavaFX refresh"); + thread.setDaemon(true); + return thread; + } + }); + } + + public synchronized void start(Listener listener) { + Objects.requireNonNull(listener, "listener"); + if (closed.get()) { + throw new IllegalStateException("JavaFX refresh coordinator is closed"); + } + if (!running.compareAndSet(false, true)) { + return; + } + this.listener = listener; + ticker = scheduler.scheduleWithFixedDelay(this::tick, 0, interval.toMillis(), TimeUnit.MILLISECONDS); + } + + public synchronized void stop() { + if (!running.compareAndSet(true, false)) { + return; + } + ScheduledFuture currentTicker = ticker; + if (currentTicker != null) { + currentTicker.cancel(false); + ticker = null; + } + CompletableFuture currentRequest = inFlight.getAndSet(null); + if (currentRequest != null) { + currentRequest.cancel(false); + } + } + + private synchronized void tick() { + if (!running.get() || closed.get() || gateway.isClosed()) { + return; + } + if (inFlight.get() != null) { + return; + } + CompletableFuture refresh = + gateway.requestOverviewAndConnectionsSnapshot(); + inFlight.set(refresh); + refresh.whenComplete((snapshot, error) -> finish(refresh, snapshot, error)); + } + + private void finish(CompletableFuture request, + KLALBOverviewConnectionsSnapshot snapshot, Throwable error) { + if (!running.get() || closed.get() || gateway.isClosed()) { + inFlight.compareAndSet(request, null); + return; + } + gateway.runOnFx(() -> { + inFlight.compareAndSet(request, null); + if (!running.get() || closed.get() || gateway.isClosed()) { + return; + } + Listener currentListener = listener; + if (error != null) { + currentListener.onError(error); + } else { + currentListener.onOverview(snapshot.getOverview()); + currentListener.onConnections(snapshot.getConnections()); + } + }).whenComplete((ignored, dispatchError) -> inFlight.compareAndSet(request, null)); + } + + void closeFromGateway() { + if (closed.compareAndSet(false, true)) { + stop(); + scheduler.shutdownNow(); + gateway.unregisterRefreshCoordinator(this); + } + } + + @Override + public void close() { + closeFromGateway(); + } + + private static Duration validateInterval(Duration interval) { + Objects.requireNonNull(interval, "interval"); + if (interval.isNegative() || interval.isZero() || interval.toMillis() <= 0) { + throw new IllegalArgumentException("refresh interval must be positive"); + } + return interval; + } + + public interface Listener { + /** All callbacks are delivered on the JavaFX application thread. */ + void onOverview(KLALBStatusSnapshot snapshot); + + void onConnections(KLALBConnectionsSnapshot snapshot); + + void onError(Throwable error); + } +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/KLALBOverviewConnectionsSnapshot.java b/src/org/kne/cloud/network/klalb/ui/fx/KLALBOverviewConnectionsSnapshot.java new file mode 100644 index 0000000..a127a46 --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/KLALBOverviewConnectionsSnapshot.java @@ -0,0 +1,28 @@ +package org.kne.cloud.network.klalb.ui.fx; + +/** + * Immutable overview and connection data captured in one controller pass. + */ +public final class KLALBOverviewConnectionsSnapshot { + private final KLALBStatusSnapshot overview; + private final KLALBConnectionsSnapshot connections; + + private KLALBOverviewConnectionsSnapshot(KLALBStatusSnapshot overview, + KLALBConnectionsSnapshot connections) { + this.overview = overview; + this.connections = connections; + } + + static KLALBOverviewConnectionsSnapshot of(KLALBStatusSnapshot overview, + KLALBConnectionsSnapshot connections) { + return new KLALBOverviewConnectionsSnapshot(overview, connections); + } + + public KLALBStatusSnapshot getOverview() { + return overview; + } + + public KLALBConnectionsSnapshot getConnections() { + return connections; + } +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/KLALBStatusSnapshot.java b/src/org/kne/cloud/network/klalb/ui/fx/KLALBStatusSnapshot.java new file mode 100644 index 0000000..1a1bd97 --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/KLALBStatusSnapshot.java @@ -0,0 +1,267 @@ +package org.kne.cloud.network.klalb.ui.fx; + +import java.util.List; + +import org.kne.cloud.network.ipv6.IPv6NetworkLink; +import org.kne.cloud.network.klalb.KLALBController; +import org.kne.cloud.network.klalb.KLALBRemoteLink; + +/** + * Immutable presentation data captured from the controller. + */ +public final class KLALBStatusSnapshot { + private final String nodeAddress; + private final int linkCount; + private final int activeLinkCount; + private final long uploadBytesPerSecond; + private final long downloadBytesPerSecond; + private final long uploadPacketsPerSecond; + private final long downloadPacketsPerSecond; + private final long uploadBytes; + private final long downloadBytes; + private final long uploadSpeedMax; + private final long downloadSpeedMax; + private final long uploadPacketsMax; + private final long downloadPacketsMax; + private final long dataUploadBytesPerSecond; + private final long dataDownloadBytesPerSecond; + private final long dataUploadPacketsPerSecond; + private final long dataDownloadPacketsPerSecond; + private final long dataUploadBytes; + private final long dataDownloadBytes; + private final long onlineDeviceCount; + private final long backplaneDelayNanos; + private final long backplanePacketsPerSecond; + private final long backplanePacketsMax; + private final double backplaneEcnRate; + private final double backplaneLossRate; + private final String clockTime; + private final long clockFrequency; + private final double clockFrequencyOffsetPpm; + + private KLALBStatusSnapshot(String nodeAddress, int linkCount, int activeLinkCount, + long uploadBytesPerSecond, long downloadBytesPerSecond, long uploadPacketsPerSecond, + long downloadPacketsPerSecond, long uploadBytes, long downloadBytes, long uploadSpeedMax, + long downloadSpeedMax, long uploadPacketsMax, long downloadPacketsMax, + long dataUploadBytesPerSecond, long dataDownloadBytesPerSecond, + long dataUploadPacketsPerSecond, long dataDownloadPacketsPerSecond, + long dataUploadBytes, long dataDownloadBytes, long onlineDeviceCount, + long backplaneDelayNanos, long backplanePacketsPerSecond, long backplanePacketsMax, + double backplaneEcnRate, double backplaneLossRate, String clockTime, + long clockFrequency, double clockFrequencyOffsetPpm) { + this.nodeAddress = nodeAddress; + this.linkCount = linkCount; + this.activeLinkCount = activeLinkCount; + this.uploadBytesPerSecond = uploadBytesPerSecond; + this.downloadBytesPerSecond = downloadBytesPerSecond; + this.uploadPacketsPerSecond = uploadPacketsPerSecond; + this.downloadPacketsPerSecond = downloadPacketsPerSecond; + this.uploadBytes = uploadBytes; + this.downloadBytes = downloadBytes; + this.uploadSpeedMax = uploadSpeedMax; + this.downloadSpeedMax = downloadSpeedMax; + this.uploadPacketsMax = uploadPacketsMax; + this.downloadPacketsMax = downloadPacketsMax; + this.dataUploadBytesPerSecond = dataUploadBytesPerSecond; + this.dataDownloadBytesPerSecond = dataDownloadBytesPerSecond; + this.dataUploadPacketsPerSecond = dataUploadPacketsPerSecond; + this.dataDownloadPacketsPerSecond = dataDownloadPacketsPerSecond; + this.dataUploadBytes = dataUploadBytes; + this.dataDownloadBytes = dataDownloadBytes; + this.onlineDeviceCount = onlineDeviceCount; + this.backplaneDelayNanos = backplaneDelayNanos; + this.backplanePacketsPerSecond = backplanePacketsPerSecond; + this.backplanePacketsMax = backplanePacketsMax; + this.backplaneEcnRate = backplaneEcnRate; + this.backplaneLossRate = backplaneLossRate; + this.clockTime = clockTime; + this.clockFrequency = clockFrequency; + this.clockFrequencyOffsetPpm = clockFrequencyOffsetPpm; + } + + static KLALBStatusSnapshot capture(KLALBController controller) { + return capture(controller, controller.getLines()); + } + + static KLALBStatusSnapshot capture(KLALBController controller, List links) { + int linkCount = 0; + int activeLinkCount = 0; + for (IPv6NetworkLink link : links) { + if (!(link instanceof KLALBRemoteLink)) { + continue; + } + linkCount++; + if (link.isUp()) { + activeLinkCount++; + } + } + return capture(controller, linkCount, activeLinkCount); + } + + static KLALBStatusSnapshot capture(KLALBController controller, + KLALBConnectionsSnapshot connections) { + int activeLinkCount = 0; + for (KLALBConnectionSnapshot connection : connections.getRows()) { + if (connection.isUp()) { + activeLinkCount++; + } + } + return capture(controller, connections.getConnectionCount(), activeLinkCount); + } + + private static KLALBStatusSnapshot capture(KLALBController controller, + int linkCount, int activeLinkCount) { + org.kne.cloud.network.monitor.SpeedAndTrafficMonitorData linkMonitor = controller.getLinkMonitor(); + org.kne.cloud.network.monitor.SpeedAndTrafficMonitorData dataMonitor = controller.getDatatMonitor(); + org.kne.cloud.network.srv6.SRv6Router router = controller.getIpv6Router(); + long onlineDeviceCount = router == null || router.getKlalbRouteProtol() == null + ? 0 : router.getKlalbRouteProtol().getDevicesFound(); + long backplaneDelayNanos = router == null ? 0 : router.getBackplaneTime(); + long backplanePacketsPerSecond = router == null ? 0 : router.getBackplanePPS(); + long backplanePacketsMax = router == null ? 0 : router.getBackplanePPSMax(); + double backplaneEcnRate = router == null ? 0 : router.getECNRate(); + double backplaneLossRate = router == null ? 0 : router.getLossRate(); + long clockFrequency = controller.getClock().getFrequency(); + return new KLALBStatusSnapshot( + controller.getSelf().getAddress().toString(), + linkCount, + activeLinkCount, + linkMonitor.getOutSpeed(), + linkMonitor.getInSpeed(), + linkMonitor.getOutPPS(), + linkMonitor.getInPPS(), + linkMonitor.getOutTraffic(), + linkMonitor.getInTraffic(), + linkMonitor.getOutSpeedMax(), + linkMonitor.getInSpeedMax(), + linkMonitor.getOutPPSMax(), + linkMonitor.getInPPSMax(), + dataMonitor.getOutSpeed(), + dataMonitor.getInSpeed(), + dataMonitor.getOutPPS(), + dataMonitor.getInPPS(), + dataMonitor.getOutTraffic(), + dataMonitor.getInTraffic(), + onlineDeviceCount, + backplaneDelayNanos, + backplanePacketsPerSecond, + backplanePacketsMax, + backplaneEcnRate, + backplaneLossRate, + controller.getClock().toString2(), + clockFrequency, + (clockFrequency - 1000000000L) / 1000.0); + } + + public String getNodeAddress() { + return nodeAddress; + } + + public int getLinkCount() { + return linkCount; + } + + public int getActiveLinkCount() { + return activeLinkCount; + } + + public long getUploadBytesPerSecond() { + return uploadBytesPerSecond; + } + + public long getDownloadBytesPerSecond() { + return downloadBytesPerSecond; + } + + public long getUploadPacketsPerSecond() { + return uploadPacketsPerSecond; + } + + public long getDownloadPacketsPerSecond() { + return downloadPacketsPerSecond; + } + + public long getUploadBytes() { + return uploadBytes; + } + + public long getDownloadBytes() { + return downloadBytes; + } + + public long getUploadSpeedMax() { + return uploadSpeedMax; + } + + public long getDownloadSpeedMax() { + return downloadSpeedMax; + } + + public long getUploadPacketsMax() { + return uploadPacketsMax; + } + + public long getDownloadPacketsMax() { + return downloadPacketsMax; + } + + public long getDataUploadBytesPerSecond() { + return dataUploadBytesPerSecond; + } + + public long getDataDownloadBytesPerSecond() { + return dataDownloadBytesPerSecond; + } + + public long getDataUploadPacketsPerSecond() { + return dataUploadPacketsPerSecond; + } + + public long getDataDownloadPacketsPerSecond() { + return dataDownloadPacketsPerSecond; + } + + public long getDataUploadBytes() { + return dataUploadBytes; + } + + public long getDataDownloadBytes() { + return dataDownloadBytes; + } + + public long getOnlineDeviceCount() { + return onlineDeviceCount; + } + + public long getBackplaneDelayNanos() { + return backplaneDelayNanos; + } + + public long getBackplanePacketsPerSecond() { + return backplanePacketsPerSecond; + } + + public long getBackplanePacketsMax() { + return backplanePacketsMax; + } + + public double getBackplaneEcnRate() { + return backplaneEcnRate; + } + + public double getBackplaneLossRate() { + return backplaneLossRate; + } + + public String getClockTime() { + return clockTime; + } + + public long getClockFrequency() { + return clockFrequency; + } + + public double getClockFrequencyOffsetPpm() { + return clockFrequencyOffsetPpm; + } +} diff --git a/src/org/kne/cloud/network/klalb/ui/fx/klalb-operations.css b/src/org/kne/cloud/network/klalb/ui/fx/klalb-operations.css new file mode 100644 index 0000000..001aa0a --- /dev/null +++ b/src/org/kne/cloud/network/klalb/ui/fx/klalb-operations.css @@ -0,0 +1,64 @@ +.fx-console { + -fx-font-family: "Microsoft YaHei UI", "Noto Sans CJK SC", sans-serif; + -fx-background-color: #f4f6f8; + -fx-text-fill: #17212b; + -fx-focus-color: #167d78; + -fx-faint-focus-color: rgba(22, 125, 120, .18); +} +.topbar { -fx-background-color: #ffffff; -fx-border-color: #dfe5e8; -fx-border-width: 0 0 1 0; } +.product-mark { -fx-font-size: 20px; -fx-font-weight: bold; -fx-text-fill: #123b43; } +.product-hint { -fx-font-size: 12px; -fx-text-fill: #6d7b83; } +.page-title { -fx-font-size: 16px; -fx-font-weight: bold; -fx-text-fill: #17212b; } +.status-dot { -fx-font-size: 12px; -fx-text-fill: #bd7b18; } +.status-label { -fx-font-size: 13px; -fx-font-weight: bold; -fx-text-fill: #3e4c54; } +.status-detail { -fx-font-size: 12px; -fx-text-fill: #829098; } +.sidebar { -fx-background-color: #132c35; } +.nav-caption { -fx-font-size: 11px; -fx-font-weight: bold; -fx-text-fill: #86a1a6; } +.navigation .button { + -fx-background-color: transparent; -fx-background-radius: 4px; -fx-border-radius: 4px; + -fx-alignment: CENTER_LEFT; -fx-padding: 0 14px; -fx-font-size: 13px; -fx-text-fill: #c5d3d5; +} +.navigation .button:hover { -fx-background-color: #1e424c; -fx-text-fill: #ffffff; } +.navigation .button:selected { -fx-background-color: #247b78; -fx-text-fill: #ffffff; } +.navigation .button:focused { -fx-border-color: #8dd2c7; -fx-border-width: 1px; } +.content-stage { -fx-background-color: #f4f6f8; } +.empty-state { -fx-padding: 32px; -fx-border-color: #d9e0e3; -fx-border-width: 1px; -fx-border-radius: 5px; -fx-background-color: #ffffff; } +.eyebrow { -fx-font-size: 11px; -fx-font-weight: bold; -fx-text-fill: #247b78; } +.empty-title { -fx-font-size: 22px; -fx-font-weight: bold; -fx-text-fill: #17212b; } +.empty-message { -fx-font-size: 13px; -fx-text-fill: #697980; -fx-line-spacing: 3px; } +.loading-state { -fx-border-color: #c6ddd9; } +.error-state { -fx-border-color: #e4c9c2; } +.footer { -fx-background-color: #ffffff; -fx-border-color: #dfe5e8; -fx-border-width: 1px 0 0 0; } +.footer-label { -fx-font-size: 11px; -fx-text-fill: #829098; } +.page-body { -fx-max-width: 1120px; } +.view-title { -fx-font-size: 22px; -fx-font-weight: bold; -fx-text-fill: #17212b; } +.view-subtitle { -fx-font-size: 13px; -fx-text-fill: #697980; } +.metric-row, .overview-panels { -fx-hgap: 12px; -fx-vgap: 12px; -fx-max-width: 1120px; } +.metric { -fx-background-color: #ffffff; -fx-border-color: #d9e0e3; -fx-border-radius: 5px; -fx-background-radius: 5px; -fx-padding: 18px; -fx-min-height: 92px; -fx-min-width: 160px; -fx-pref-width: 220px; -fx-max-width: 280px; } +.metric-label { -fx-font-size: 12px; -fx-text-fill: #697980; } +.metric-value { -fx-font-size: 22px; -fx-font-weight: bold; -fx-text-fill: #123b43; } +.data-panel { -fx-background-color: #ffffff; -fx-border-color: #d9e0e3; -fx-border-radius: 5px; -fx-background-radius: 5px; -fx-padding: 18px; } +.overview-panels .data-panel { -fx-min-width: 280px; -fx-pref-width: 420px; -fx-max-width: 1120px; } +.panel-title { -fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #17212b; } +.data-line { -fx-padding: 7px 0; -fx-border-color: #edf0f1; -fx-border-width: 0 0 1px 0; } +.line-label { -fx-text-fill: #697980; -fx-font-size: 13px; } +.line-value { -fx-alignment: CENTER_RIGHT; -fx-text-fill: #17212b; -fx-font-size: 13px; } +.address-row { -fx-background-color: #eaf3f2; -fx-background-radius: 4px; -fx-padding: 12px 14px; } +.address-row .label { -fx-text-fill: #24565a; -fx-font-size: 13px; } +.action-row { -fx-padding: 0 0 4px 0; } +.action-row .button, .connection-row .button { -fx-background-color: #ffffff; -fx-border-color: #cfd9dc; -fx-border-radius: 4px; -fx-background-radius: 4px; -fx-text-fill: #24444c; } +.action-row .button:hover, .connection-row .button:hover { -fx-background-color: #eaf3f2; -fx-border-color: #247b78; } +.action-row .button:focused, .connection-row .button:focused { -fx-border-color: #167d78; -fx-border-width: 2px; } +.connection-scroll { -fx-background-color: transparent; -fx-border-color: transparent; } +.connection-scroll > .viewport { -fx-background-color: transparent; } +.connection-row { -fx-background-color: #ffffff; -fx-border-color: #d9e0e3; -fx-border-radius: 5px; -fx-background-radius: 5px; -fx-padding: 14px; } +.row-actions { -fx-alignment: CENTER_RIGHT; } +.row-actions .button { -fx-wrap-text: true; -fx-min-height: 28px; } +.connection-row .vbox { -fx-min-width: 0; } +.row-name { -fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #17212b; } +.row-address, .row-metrics { -fx-font-size: 12px; -fx-text-fill: #697980; } +.state-online, .state-unstable, .state-offline { -fx-font-size: 11px; -fx-font-weight: bold; -fx-padding: 3px 7px; -fx-background-radius: 3px; } +.state-online { -fx-background-color: #dcefe8; -fx-text-fill: #216c55; } +.state-offline { -fx-background-color: #f1e4df; -fx-text-fill: #95513d; } +.state-unstable { -fx-background-color: #f4ecd6; -fx-text-fill: #87631d; } +.empty-message { -fx-font-size: 13px; -fx-text-fill: #697980; }