diff --git a/Dockerfile b/Dockerfile index a4bd7f7..f9f5cb5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,5 +24,5 @@ LABEL org.opencontainers.image.vendor="neuland – Büro für Informatik GmbH" LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.title="bandwhichd-ui" LABEL org.opencontainers.image.description="bandwhichd ui displaying network topology and statistics" -LABEL org.opencontainers.image.version="0.2.0" +LABEL org.opencontainers.image.version="0.3.0" COPY --from=build --chown=root:root /home/node/dist /usr/share/nginx/html \ No newline at end of file diff --git a/package.json b/package.json index e88cb6b..1674f66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bandwhichd-ui", - "version": "0.2.0", + "version": "0.3.0", "description": "bandwhichd ui displaying network topology and statistics", "license": "MIT", "private": true, diff --git a/src/HostDetails.module.css b/src/HostDetails.module.css index 2f10ce9..911608c 100644 --- a/src/HostDetails.module.css +++ b/src/HostDetails.module.css @@ -1,7 +1,17 @@ -.hostDetails { +.host-details { background-color: #eeeeee; } -.hostDetails > section { +.host-details > section { padding: .5em; +} + +.host-details > section > ul { + list-style-type: none; + margin: 0; + padding: 0; +} + +.host-details > section > ul > li + li { + margin-top: .5em; } \ No newline at end of file diff --git a/src/HostDetails.tsx b/src/HostDetails.tsx index 48f4774..8e3d517 100644 --- a/src/HostDetails.tsx +++ b/src/HostDetails.tsx @@ -1,9 +1,12 @@ -import { Host } from "./Stats"; +import { Map } from "immutable"; +import { Connection, Host, HostId, Stats, UnmonitoredHost } from "./Stats"; import style from "./HostDetails.module.css"; export interface HostDetailsProps { - maybeSelectedHost: Host | null; + maybeStats: Stats | null; + maybeSelectedHost: Host & { hostId: HostId } | null; + setMaybeSelectedHostId: (maybeSelectedHostId: HostId | null) => void, } export const HostDetails: React.FC<HostDetailsProps> = @@ -13,9 +16,95 @@ export const HostDetails: React.FC<HostDetailsProps> = } const selectedHost = props.maybeSelectedHost; - return <aside className={style.hostDetails}> + const connectionsToMonitoredHosts = selectedHost + .connections + .flatMap<HostId, { hostDetails: Host, connectionDetails: Connection }>((connection, hostId) => { + if (props.maybeStats === null) { + return Map<HostId, { hostDetails: Host, connectionDetails: Connection }>(); + } + const stats = props.maybeStats; + + const maybeMonitoredHost = stats.hosts.get(hostId, null); + if (maybeMonitoredHost === null) { + return Map<HostId, { hostDetails: Host, connectionDetails: Connection }>(); + } + const monitoredHost = maybeMonitoredHost; + + return Map<HostId, { hostDetails: Host, connectionDetails: Connection }>({ + [hostId]: { + hostDetails: monitoredHost, + connectionDetails: connection, + }, + }); + }); + + const connectionsToUnmonitoredHosts = selectedHost + .connections + .flatMap<HostId, { unmonitoredHostDetails: UnmonitoredHost, connectionDetails: Connection }>((connection, hostId) => { + if (props.maybeStats === null) { + return Map<HostId, { unmonitoredHostDetails: UnmonitoredHost, connectionDetails: Connection }>(); + } + const stats = props.maybeStats; + + const maybeUnmonitoredHost = stats.unmonitoredHosts.get(hostId, null); + if (maybeUnmonitoredHost === null) { + return Map<HostId, { unmonitoredHostDetails: UnmonitoredHost, connectionDetails: Connection }>(); + } + const unmonitoredHost = maybeUnmonitoredHost; + + return Map<HostId, { unmonitoredHostDetails: UnmonitoredHost, connectionDetails: Connection }>({ + [hostId]: { + unmonitoredHostDetails: unmonitoredHost, + connectionDetails: connection, + }, + }); + }); + + return <aside className={style["host-details"]}> <section> <p>{selectedHost.hostname}</p> </section> + <section> + { + connectionsToMonitoredHosts.isEmpty() && <p>No connections to monitored hosts</p> + } + { + !connectionsToMonitoredHosts.isEmpty() && <> + <p>Connections to monitored hosts:</p> + <ul> + { + connectionsToMonitoredHosts + .entrySeq() + .sortBy(([_, {hostDetails}]) => hostDetails.hostname) + .map(([hostId, { hostDetails }]) => <li key={hostId}> + { + hostId === selectedHost.hostId + ? <button disabled>{hostDetails.hostname}</button> + : <button onClick={_ => props.setMaybeSelectedHostId(hostId)}>{hostDetails.hostname}</button> + } + </li>) + } + </ul> + </> + } + </section> + <section> + { + connectionsToUnmonitoredHosts.isEmpty() && <p>No connections to unmonitored hosts</p> + } + { + !connectionsToUnmonitoredHosts.isEmpty() && <> + <p>Connections to unmonitored hosts:</p> + <ul> + { + connectionsToUnmonitoredHosts + .entrySeq() + .sortBy(([_, {unmonitoredHostDetails}]) => unmonitoredHostDetails.host) + .map(([hostId, { unmonitoredHostDetails }]) => <li key={hostId}>{unmonitoredHostDetails.host}</li>) + } + </ul> + </> + } + </section> </aside>; }; \ No newline at end of file diff --git a/src/Main.module.css b/src/Main.module.css index f89b2f1..eb34396 100644 --- a/src/Main.module.css +++ b/src/Main.module.css @@ -8,6 +8,7 @@ .main > aside { flex-grow: 0; flex-shrink: 0; + overflow-y: scroll; } .main > section { diff --git a/src/Main.tsx b/src/Main.tsx index 868ab05..b106d0d 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -12,10 +12,15 @@ export const Main: React.FC = const [maybeStats, setMaybeStats] = useState<Stats | null>(null); const [maybeSelectedHostId, setMaybeSelectedHostId] = useState<HostId | null>(null); - const maybeSelectedHost: Host | null = + const maybeSelectedHostWithoutId = maybeStats === null || maybeSelectedHostId === null ? null - : maybeStats.hosts.get(maybeSelectedHostId, null); + : maybeStats.hosts.get(maybeSelectedHostId, null) + + const maybeSelectedHost: Host & { hostId: HostId } | null = + maybeSelectedHostId === null || maybeSelectedHostWithoutId == null + ? null + : { hostId: maybeSelectedHostId, ...maybeSelectedHostWithoutId }; useEffect(() => { fetchStats(configuration).then(stats => { @@ -26,6 +31,6 @@ export const Main: React.FC = return <main className={styles.main}> <NodeSelector {...{ maybeStats, maybeSelectedHostId, setMaybeSelectedHostId }} /> <Graph {...{ maybeSelectedHostId, setMaybeSelectedHostId }} /> - <HostDetails {...{ maybeSelectedHost }} /> + <HostDetails {...{ maybeStats, maybeSelectedHost, setMaybeSelectedHostId }} /> </main>; }; \ No newline at end of file diff --git a/src/Stats.ts b/src/Stats.ts index ecda98b..6f3049d 100644 --- a/src/Stats.ts +++ b/src/Stats.ts @@ -9,35 +9,49 @@ import { decode } from "./lib/io-ts/promiseUtils"; const hostIdTag = Symbol("HostId"); export type HostId = string & { readonly _tag: typeof hostIdTag; }; const createHostId: (value: string) => HostId = (value) => value as HostId; -export const hostIdDecoder = Decoder.map(createHostId)(Decoder.string); +const hostIdDecoder = Decoder.map(createHostId)(Decoder.string); const hostnameTag = Symbol("Hostname"); export type Hostname = string & { readonly _tag: typeof hostnameTag; }; const createHostname: (value: string) => Hostname = (value) => value as Hostname; -export const hostnameDecoder = Decoder.map(createHostname)(Decoder.string); +const hostnameDecoder = Decoder.map(createHostname)(Decoder.string); + +export interface Connection { } +const connectionDecoder = Decoder.struct({}); export interface Host { readonly hostname: Hostname; + readonly connections: Map<HostId, Connection>; } -export const hostDecoder = Decoder.struct({ +const hostDecoder = Decoder.struct({ hostname: hostnameDecoder, + connections: mapDecoder(hostIdDecoder, connectionDecoder), +}); + +export interface UnmonitoredHost { + host: string; +} +const unmonitoredHostDecoder = Decoder.struct({ + host: Decoder.string, }); export interface Stats { - hosts: Map<HostId, Host> + hosts: Map<HostId, Host>; + unmonitoredHosts: Map<HostId, UnmonitoredHost>; } -export const statsDecoder = Decoder.struct({ +const statsDecoder = Decoder.struct({ hosts: mapDecoder(hostIdDecoder, hostDecoder), + unmonitoredHosts: mapDecoder(hostIdDecoder, unmonitoredHostDecoder), }); -export const fetchStats = +export const fetchStats = async (configuration: Configuration): Promise<Stats> => - await fetch(`${configuration.apiServer}/v1/stats`, { + await window.fetch(`${configuration.apiServer}/v1/stats`, { method: "GET", headers: { "Accept": "application/json; q=1.0" } }) - .then(rejectNonOk()) - .then(parseBodyAsJson()) - .then(decode(statsDecoder)); \ No newline at end of file + .then(rejectNonOk()) + .then(parseBodyAsJson()) + .then(decode(statsDecoder)); \ No newline at end of file diff --git a/src/index.css b/src/index.css index 104c2b4..239e5d0 100644 --- a/src/index.css +++ b/src/index.css @@ -5,5 +5,5 @@ html, body, #bandwhichd-root { #bandwhichd-root { display: grid; grid-template-columns: 1fr; - grid-template-rows: min-content 1fr min-content; + grid-template-rows: min-content minmax(20em, 1fr) min-content; } \ No newline at end of file