Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,24 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^19",
"react-dom": "^19",
"@toolpad/core": "0.16.0",
"@mui/material": "^7",
"@mui/material-nextjs": "^7",
"@mui/icons-material": "^7",
"@emotion/react": "^11",
"@emotion/styled": "^11",
"zod": "^3.24.2",
"react-router": "^7"
"@mui/icons-material": "^7",
"@mui/material": "^7",
"@mui/material-nextjs": "^7",
"@mui/x-tree-view": "^8.11.2",
"@toolpad/core": "0.16.0",
"react": "^19",
"react-dom": "^19",
"react-router": "^7",
"zod": "^3.24.2"
},
"devDependencies": {
"typescript": "^5",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"@vitejs/plugin-react": "^4.3.2",
"vite": "^5.4.8"
"eslint": "^8",
"typescript": "^5",
"vite": "^7.1.5"
}
}
}
6 changes: 3 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// SPDX-FileCopyrightText: 2025 Sidings Media <contact@sidingsmedia.com>
// SPDX-License-Identifier: MIT

import * as React from "react";
import DnsIcon from "@mui/icons-material/Dns";
import CachedIcon from "@mui/icons-material/Cached";
import DashboardIcon from "@mui/icons-material/Dashboard";
Expand All @@ -12,7 +11,7 @@ import OpenInNewIcon from "@mui/icons-material/OpenInNew";

let navigation: Navigation = [
{
segment: "",
segment: "overview",
title: "Overview",
icon: <DashboardIcon />,
},
Expand All @@ -24,7 +23,7 @@ let navigation: Navigation = [
icon: <DnsIcon />,
children: [
{
segment: "rdns/cache",
segment: "cache",
title: "Cache",
icon: <CachedIcon />,
},
Expand All @@ -34,6 +33,7 @@ let navigation: Navigation = [
{ kind: "header", title: "External" },
];

//@ts-expect-error
for (const link of JSON.parse(import.meta.env.VITE_EXTERNAL_SIDEBAR_LINKS)) {
navigation.push({
title: link.title,
Expand Down
174 changes: 174 additions & 0 deletions src/api/Api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// SPDX-FileCopyrightText: 2025 Sidings Media <contact@sidingsmedia.com>
// SPDX-License-Identifier: MIT

import { useNotifications } from "@toolpad/core/useNotifications";
import { ApiError } from "./ApiError";
import type { Error } from "./responses/common";
import { NOTIFICATION_DISPLAY_TIME } from "./constants";

export type DecodeAs = "json" | "text" | "blob";

export class Api {
private baseUrl: string =
// @ts-expect-error
(import.meta.env.VITE_API_URL as string | undefined) ??
"http://localhost:8080/v1/";

/**
* Make a GET request to the API
* @param path Endpoint path to call
* @param options Standard fetch() options
* @returns API response
*/
public async get<T>(path: string, options?: RequestInit): Promise<T> {
const completeOptions = options ?? {};
return this.request(path, "get", completeOptions);
}

/**
* Make a POST request to the API
* @param path Endpoint path to call
* @param options Standard fetch() options
* @returns API response
*/
public async post<T>(path: string, options: RequestInit): Promise<T> {
return this.request(path, "post", options);
}

/**
* Make a PUT request to the API
* @param path Endpoint path to call
* @param options Standard fetch() options
* @returns API response
*/
public async put<T>(path: string, options: RequestInit): Promise<T> {
return this.request(path, "put", options);
}

/**
* Make a PATCH request to the API
* @param path Endpoint path to call
* @param options Standard fetch() options
* @returns API response
*/
public async patch<T>(path: string, options: RequestInit): Promise<T> {
return this.request(path, "patch", options);
}

/**
* Make a DELETE request to the API
* @param path Endpoint path to call
* @param options Standard fetch() options
* @returns API response
*/
public async delete<T>(path: string, options?: RequestInit): Promise<T> {
const completeOptions = options ?? {};
return this.request(path, "delete", completeOptions);
}

/**
* Make request to API. Generally one of the other methods (e.g.
* get(), post() and so on) should be used rather than this one. Only
* use this one if you need more flexibility (e.g. return a blob).
*
* @param path Endpoint path
* @param method HTTP method
* @param options Standard fetch() options
* @param baseUrl Optional alternate base url to use
* @param [decodeAs="json"] How should the response be decoded? Defaults
* to `json`.
* @param [contentType="application/json"] Content type to use.
* `Defaults to application/json`
* @returns API response
*/
public async request<T>(
path: string,
method: "get" | "post" | "put" | "patch" | "delete",
options: RequestInit,
baseUrl?: string,
decodeAs: DecodeAs = "json",
contentType = "application/json",
): Promise<T> {
const opts = {
...options,
method,
};

opts.headers = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...opts.headers,
"Content-type": contentType,
...this.headers(),
};

let url: URL;
if (baseUrl === undefined) {
// Handle when base URL is set to relative path
if (!this.baseUrl.startsWith("http")) {
this.baseUrl = new URL(this.baseUrl, window.location.origin).toString();
}
url = new URL(path, this.baseUrl);
} else {
url = new URL(path, baseUrl);
}

console.log(`Sending request: ${method.toUpperCase()} ${url.toString()}`);
const response = await fetch(url.toString(), opts);
if (!response.ok) {
const data: Error = (await response.json()) as Error;
throw new ApiError(data.message, data);
}

if (response.status === 204) {
return undefined as T;
}

switch (decodeAs) {
case "json":
return response.json() as Promise<T>;
case "text":
return response.text() as Promise<T>;
case "blob":
return response.blob() as T;
}
}

/**
* Get the required headers for requests
* @returns No headers needed
*/
private headers(): Record<string, string> {
return {};
}

/**
* Create a URL using the configured base URL.
*
* @param path Path to append to base URL
* @returns Correctly formatted URL
*/
public constructUrl(path: string): URL {
// Handle when base URL is set to relative path
if (!this.baseUrl.startsWith("http")) {
this.baseUrl = new URL(this.baseUrl, window.location.origin).toString();
}
return new URL(path, this.baseUrl);
}

public handleError(
e: unknown,
notifications: ReturnType<typeof useNotifications>,
): void {
if (e instanceof ApiError) {
notifications.show(e.error.message, {
severity: "error",
autoHideDuration: NOTIFICATION_DISPLAY_TIME,
});
} else {
notifications.show(`Network error: ${e}`, {
severity: "error",
autoHideDuration: NOTIFICATION_DISPLAY_TIME,
});
}
}
}
15 changes: 15 additions & 0 deletions src/api/ApiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2025 Sidings Media <contact@sidingsmedia.com>
// SPDX-License-Identifier: MIT

import type { Error as ErrorResponse } from "./responses/common";

export class ApiError extends Error {
public error: ErrorResponse;

public constructor(message: string, error: ErrorResponse) {
super(message);
Object.setPrototypeOf(this, ApiError.prototype);

this.error = error;
}
}
4 changes: 4 additions & 0 deletions src/api/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// SPDX-FileCopyrightText: 2025 Sidings Media <contact@sidingsmedia.com>
// SPDX-License-Identifier: MIT

export const NOTIFICATION_DISPLAY_TIME = 5000;
11 changes: 11 additions & 0 deletions src/api/responses/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2025 Sidings Media <contact@sidingsmedia.com>
// SPDX-License-Identifier: MIT

export interface Error {
code: number;
message: string;
}

export interface List<T> {
results: T[];
}
25 changes: 25 additions & 0 deletions src/api/responses/rdns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2025 Sidings Media <contact@sidingsmedia.com>
// SPDX-License-Identifier: MIT

export interface Server {
name: string;
target: string;
id: string;
}

export interface CachedResult {
id: string;
data: Record<string, any>;
ttl: string;
}

export interface CacheEntry {
name: string;
type: string;
cachedResults: CachedResult[];
}

export interface CacheResponse {
zones: string[];
entries: CacheEntry[];
}
8 changes: 8 additions & 0 deletions src/components/common/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2025 Sidings Media <contact@sidingsmedia.com>
// SPDX-License-Identifier: MIT

export { SearchBar } from "./search_bar";
export type { SearchBarProps } from "./search_bar";

export { ServerSelect } from "./server_select";
export type { ServerSelectProps, ServerSelectOption } from "./server_select";
62 changes: 62 additions & 0 deletions src/components/common/search_bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2025 Sidings Media <contact@sidingsmedia.com>
// SPDX-License-Identifier: MIT

import type { SxProps, Theme } from "@mui/material";

import Box from "@mui/material/Box";
import FormControl from "@mui/material/FormControl";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import InputLabel from "@mui/material/InputLabel";
import OutlinedInput from "@mui/material/OutlinedInput";
import React from "react";
import SearchIcon from "@mui/icons-material/Search";

export type SearchBarProps = {
readonly placeholder?: string;
readonly ariaLabel?: string;
readonly sx?: SxProps<Theme>;
readonly loading?: boolean;
readonly onSubmit?: (val: string) => void;
};

export function SearchBar(props: SearchBarProps) {
const [query, setQuery] = React.useState("");

return (
<Box
component="form"
onSubmit={(event) => {
event.preventDefault();
if (props.onSubmit) {
props.onSubmit(query);
}
}}
>
<FormControl sx={props.sx}>
<InputLabel htmlFor="search-bar">
{props.placeholder ?? "Search"}
</InputLabel>
<OutlinedInput
id="search-bar"
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
endAdornment={
<InputAdornment position="end">
<IconButton
loading={props.loading}
aria-label={props.ariaLabel ?? "search"}
edge="end"
type="submit"
>
<SearchIcon />
</IconButton>
</InputAdornment>
}
label={props.placeholder ?? "Search"}
></OutlinedInput>
</FormControl>
</Box>
);
}
Loading