feat: JavaFX 初实现

This commit is contained in:
2026-08-28 16:58:34 +08:00
parent c907e52478
commit 7d29d72381
18 changed files with 1810 additions and 113 deletions
+3
View File
@@ -14,3 +14,6 @@ dashboard/.env.*.local
/klalb-config.json
/klalbconfig-old.json
# Local agent workflow state
.slim/deepwork/
+14
View File
@@ -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}"
}
]
}
+28 -87
View File
@@ -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 <component>`; 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=<ipv6>` (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 <component>` (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<InetAddress>`, `List<MultiProtocolSocketAddress>`) 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`.
+74
View File
@@ -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
+75 -1
View File
@@ -116,4 +116,78 @@ webapisettings=Web API 设置
enablewebapi=启用 Web API
weblistenaddr=Web API 监听地址:端口
invaildweblistenaddr=无效的 Web API 监听地址:端口
enabletun=启用TUN虚拟网卡
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=复制绑定地址
+70 -25
View File
@@ -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;
}
}
@@ -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")); }
}
@@ -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<Section> 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<String, ConnectionRowView> 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<Section> 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<ConnectionRowView> 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<ConnectionRowView> 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<ConnectionRowView> rows, javafx.scene.Node node) {
for (ConnectionRowView row : rows) if (row.root == node) return true;
return false;
}
private int indexOfRoot(List<ConnectionRowView> 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<String> tokens(KLALBConnectionsSnapshot snapshot) {
List<String> 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<ButtonType> 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<Integer> 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; }
}
@@ -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; }
}
@@ -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;
}
}
@@ -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<KLALBConnectionSnapshot> rows;
private KLALBConnectionsSnapshot(List<KLALBConnectionSnapshot> 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<IPv6NetworkLink> links, ConnectionIdentity identity) {
List<KLALBConnectionSnapshot> 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<KLALBConnectionSnapshot> rows) {
return new KLALBConnectionsSnapshot(rows);
}
public List<KLALBConnectionSnapshot> getRows() {
return rows;
}
public int getConnectionCount() {
return rows.size();
}
interface ConnectionIdentity {
String tokenFor(KLALBRemoteLink link);
}
}
@@ -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<KLALBJavaFXApplication> 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<KLALBJavaFXApplication> 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<Stage> openStage(Consumer<Stage> initializer) {
return openStage(() -> true, initializer);
}
CompletableFuture<Stage> openStage(BooleanSupplier allowed, Consumer<Stage> initializer) {
Objects.requireNonNull(initializer, "initializer");
CompletableFuture<Stage> 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<Void> runOnFx(Runnable action) {
return runOnFx(() -> true, action);
}
CompletableFuture<Void> runOnFx(BooleanSupplier allowed, Runnable action) {
Objects.requireNonNull(action, "action");
CompletableFuture<Void> 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;
}
}
@@ -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<CompletableFuture<?>> pendingWork = ConcurrentHashMap.newKeySet();
private final Set<KLALBJavaFXRefreshCoordinator> refreshCoordinators = ConcurrentHashMap.newKeySet();
private final IdentityHashMap<KLALBRemoteLink, String> linkTokens = new IdentityHashMap<>();
private final Map<String, KLALBRemoteLink> 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<KLALBJavaFXApplication> startAsync() {
return KLALBJavaFXApplication.startAsync();
}
@Override
public CompletableFuture<KLALBStatusSnapshot> requestStatusSnapshot() {
return requestOverviewSnapshot();
}
@Override
public CompletableFuture<KLALBStatusSnapshot> requestOverviewSnapshot() {
return submitController(KLALBStatusSnapshot::capture);
}
@Override
public CompletableFuture<KLALBConnectionsSnapshot> requestConnectionsSnapshot() {
return submitController(this::captureConnections);
}
@Override
public CompletableFuture<KLALBOverviewConnectionsSnapshot> requestOverviewAndConnectionsSnapshot() {
return submitController(this::captureOverviewAndConnections);
}
@Override
public CompletableFuture<Integer> 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<Void> reconnectNow() {
return submitController(currentController -> {
currentController.reconnectImmediately();
return null;
});
}
@Override
public CompletableFuture<Void> 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<Void> 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<Void> 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<Stage> openStage(Consumer<Stage> 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<Void> 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<CompletableFuture<?>> workToCancel;
Set<KLALBJavaFXRefreshCoordinator> 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 <T> CompletableFuture<T> submitController(ControllerOperation<T> operation) {
Objects.requireNonNull(operation, "operation");
CompletableFuture<T> 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<IPv6NetworkLink> links) {
IdentityHashMap<KLALBRemoteLink, String> currentTokens = new IdentityHashMap<>();
Map<String, KLALBRemoteLink> 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<IPv6NetworkLink> 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 <T> void completeOnFx(CompletableFuture<T> 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 <T> CompletableFuture<T> failedFuture(Throwable error) {
CompletableFuture<T> result = new CompletableFuture<>();
result.completeExceptionally(error);
return result;
}
private static IllegalStateException closedException() {
return new IllegalStateException("JavaFX gateway is closed");
}
@FunctionalInterface
private interface ControllerOperation<T> {
T run(KLALBController controller) throws Exception;
}
}
@@ -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<KLALBStatusSnapshot> requestStatusSnapshot();
CompletableFuture<KLALBStatusSnapshot> requestOverviewSnapshot();
CompletableFuture<KLALBConnectionsSnapshot> requestConnectionsSnapshot();
CompletableFuture<KLALBOverviewConnectionsSnapshot> requestOverviewAndConnectionsSnapshot();
CompletableFuture<Integer> addEndpoint(String endpoint);
CompletableFuture<Void> reconnectNow();
CompletableFuture<Void> reconnectLink(String identityToken);
CompletableFuture<Void> forceDisconnect(String identityToken);
/** Resolves one row token, then removes every live link at its socket endpoint. */
CompletableFuture<Void> removeLink(String identityToken);
}
@@ -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<CompletableFuture<?>> 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<KLALBOverviewConnectionsSnapshot> refresh =
gateway.requestOverviewAndConnectionsSnapshot();
inFlight.set(refresh);
refresh.whenComplete((snapshot, error) -> finish(refresh, snapshot, error));
}
private void finish(CompletableFuture<KLALBOverviewConnectionsSnapshot> 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);
}
}
@@ -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;
}
}
@@ -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<IPv6NetworkLink> 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;
}
}
@@ -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; }