feat(database): add native SQLite database manager and fix wireguard admin visibility
This commit is contained in:
parent
3f0d529d14
commit
b99b623355
10 changed files with 453 additions and 35 deletions
81
backend/internal/database.js
Normal file
81
backend/internal/database.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import db from "../db.js";
|
||||
import { debug, express as logger } from "../logger.js";
|
||||
|
||||
const internalDatabase = {
|
||||
/**
|
||||
* Get all tables in the database (SQLite specific, but Knex supports raw queries for others too)
|
||||
*/
|
||||
async getTables() {
|
||||
const knex = db();
|
||||
|
||||
// Attempt SQLite first, fallback to generic if using mysql/mariadb
|
||||
try {
|
||||
// For SQLite
|
||||
const tables = await knex.raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
|
||||
return tables.map(t => t.name).sort();
|
||||
} catch (e) {
|
||||
// For MySQL/MariaDB
|
||||
const tables = await knex.raw("SHOW TABLES");
|
||||
return tables[0].map(t => Object.values(t)[0]).sort();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get table schema and paginated rows
|
||||
*/
|
||||
async getTableData(tableName, limit = 50, offset = 0) {
|
||||
const knex = db();
|
||||
|
||||
// 1. Get Schema/PRAGMA
|
||||
let schema = [];
|
||||
try {
|
||||
const info = await knex.raw(`PRAGMA table_info("${tableName}")`);
|
||||
schema = info; // SQLite structure
|
||||
} catch (e) {
|
||||
// MySQL fallback
|
||||
const info = await knex.raw(`DESCRIBE \`${tableName}\``);
|
||||
schema = info[0];
|
||||
}
|
||||
|
||||
// 2. Count total rows
|
||||
const countResult = await knex(tableName).count("id as count").first();
|
||||
const total = parseInt(countResult.count || 0, 10);
|
||||
|
||||
// 3. Get rows
|
||||
const rows = await knex(tableName)
|
||||
.select("*")
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
// Try ordering by ID or created_on if possible, fallback to whatever db returns
|
||||
.orderBy(
|
||||
schema.find(col => col.name === 'created_on' || col.Field === 'created_on') ? 'created_on' :
|
||||
(schema.find(col => col.name === 'id' || col.Field === 'id') ? 'id' : undefined) || '1',
|
||||
'desc'
|
||||
)
|
||||
.catch(() => knex(tableName).select("*").limit(limit).offset(offset)); // If order fails
|
||||
|
||||
return {
|
||||
name: tableName,
|
||||
schema,
|
||||
total,
|
||||
rows
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute raw SQL query directly
|
||||
*/
|
||||
async executeQuery(queryStr) {
|
||||
const knex = db();
|
||||
|
||||
// Run raw query. This is dangerous and assumes the user knows what they're doing.
|
||||
const result = await knex.raw(queryStr);
|
||||
|
||||
return {
|
||||
query: queryStr,
|
||||
result: result
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default internalDatabase;
|
||||
|
|
@ -205,7 +205,7 @@ const internalWireguard = {
|
|||
/**
|
||||
* Get all clients with live status and interface name correlation
|
||||
*/
|
||||
async getClients(knex, access) {
|
||||
async getClients(knex, access, accessData) {
|
||||
await this.getOrCreateInterface(knex); // Ensure structure exists
|
||||
|
||||
const query = knex("wg_client")
|
||||
|
|
@ -214,7 +214,7 @@ const internalWireguard = {
|
|||
.orderBy("wg_client.created_on", "desc");
|
||||
|
||||
// Filter by owner if not admin
|
||||
if (access && !access.token.hasScope("admin")) {
|
||||
if (access && (!accessData || accessData.permission_visibility !== "all")) {
|
||||
query.andWhere("wg_client.owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
|
||||
|
|
@ -265,7 +265,7 @@ const internalWireguard = {
|
|||
/**
|
||||
* Create a new WireGuard client
|
||||
*/
|
||||
async createClient(knex, data, access) {
|
||||
async createClient(knex, data, access, accessData) {
|
||||
const iface = data.interface_id
|
||||
? await knex("wg_interface").where("id", data.interface_id).first()
|
||||
: await this.getOrCreateInterface(knex);
|
||||
|
|
@ -307,9 +307,9 @@ const internalWireguard = {
|
|||
/**
|
||||
* Delete a WireGuard client
|
||||
*/
|
||||
async deleteClient(knex, clientId, access) {
|
||||
async deleteClient(knex, clientId, access, accessData) {
|
||||
const query = knex("wg_client").where("id", clientId);
|
||||
if (access && !access.token.hasScope("admin")) {
|
||||
if (access && (!accessData || accessData.permission_visibility !== "all")) {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
|
|
@ -326,9 +326,9 @@ const internalWireguard = {
|
|||
/**
|
||||
* Toggle a WireGuard client enabled/disabled
|
||||
*/
|
||||
async toggleClient(knex, clientId, enabled, access) {
|
||||
async toggleClient(knex, clientId, enabled, access, accessData) {
|
||||
const query = knex("wg_client").where("id", clientId);
|
||||
if (access && !access.token.hasScope("admin")) {
|
||||
if (access && (!accessData || accessData.permission_visibility !== "all")) {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
|
|
@ -349,9 +349,9 @@ const internalWireguard = {
|
|||
/**
|
||||
* Update a WireGuard client
|
||||
*/
|
||||
async updateClient(knex, clientId, data, access) {
|
||||
async updateClient(knex, clientId, data, access, accessData) {
|
||||
const query = knex("wg_client").where("id", clientId);
|
||||
if (access && !access.token.hasScope("admin")) {
|
||||
if (access && (!accessData || accessData.permission_visibility !== "all")) {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
|
|
@ -412,7 +412,7 @@ const internalWireguard = {
|
|||
/**
|
||||
* Create a new WireGuard Interface Endpoint
|
||||
*/
|
||||
async createInterface(knex, data, access) {
|
||||
async createInterface(knex, data, access, accessData) {
|
||||
const existingIfaces = await knex("wg_interface").select("name", "listen_port");
|
||||
const newIndex = existingIfaces.length;
|
||||
|
||||
|
|
@ -468,9 +468,9 @@ const internalWireguard = {
|
|||
/**
|
||||
* Update an existing Interface
|
||||
*/
|
||||
async updateInterface(knex, id, data, access) {
|
||||
async updateInterface(knex, id, data, access, accessData) {
|
||||
const query = knex("wg_interface").where("id", id);
|
||||
if (access && !access.token.hasScope("admin")) {
|
||||
if (access && (!accessData || accessData.permission_visibility !== "all")) {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const iface = await query.first();
|
||||
|
|
@ -491,9 +491,9 @@ const internalWireguard = {
|
|||
/**
|
||||
* Delete an interface
|
||||
*/
|
||||
async deleteInterface(knex, id, access) {
|
||||
async deleteInterface(knex, id, access, accessData) {
|
||||
const query = knex("wg_interface").where("id", id);
|
||||
if (access && !access.token.hasScope("admin")) {
|
||||
if (access && (!accessData || accessData.permission_visibility !== "all")) {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const iface = await query.first();
|
||||
|
|
@ -516,10 +516,10 @@ const internalWireguard = {
|
|||
/**
|
||||
* Update Peering Links between WireGuard Interfaces
|
||||
*/
|
||||
async updateInterfaceLinks(knex, id, linkedServers, access) {
|
||||
async updateInterfaceLinks(knex, id, linkedServers, access, accessData) {
|
||||
// Verify ownership
|
||||
const query = knex("wg_interface").where("id", id);
|
||||
if (access && !access.token.hasScope("admin")) {
|
||||
if (access && (!accessData || accessData.permission_visibility !== "all")) {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const iface = await query.first();
|
||||
|
|
@ -544,10 +544,10 @@ const internalWireguard = {
|
|||
/**
|
||||
* Get the WireGuard interfaces info
|
||||
*/
|
||||
async getInterfacesInfo(knex, access) {
|
||||
async getInterfacesInfo(knex, access, accessData) {
|
||||
const query = knex("wg_interface").select("*");
|
||||
// Filter by owner if not admin
|
||||
if (access && !access.token.hasScope("admin")) {
|
||||
if (access && (!accessData || accessData.permission_visibility !== "all")) {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const ifaces = await query;
|
||||
|
|
|
|||
82
backend/routes/api/database.js
Normal file
82
backend/routes/api/database.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import express from "express";
|
||||
import internalDatabase from "../../internal/database.js";
|
||||
import jwtdecode from "../../lib/express/jwt-decode.js";
|
||||
import { debug, express as logger } from "../../logger.js";
|
||||
|
||||
const router = express.Router({
|
||||
caseSensitive: true,
|
||||
strict: true,
|
||||
mergeParams: true,
|
||||
});
|
||||
|
||||
router.use(jwtdecode());
|
||||
|
||||
/**
|
||||
* Middleware to strictly ensure only Super Admins can access this route
|
||||
*/
|
||||
const requireSuperAdmin = async (req, res, next) => {
|
||||
try {
|
||||
const accessData = await res.locals.access.can("proxy_hosts:list");
|
||||
if (!accessData || accessData.permission_visibility !== "all") {
|
||||
return res.status(403).json({ error: { message: "Forbidden: Super Admin only" } });
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
router.use(requireSuperAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/database/tables
|
||||
* List all tables in the database
|
||||
*/
|
||||
router.get("/tables", async (req, res, next) => {
|
||||
try {
|
||||
const tables = await internalDatabase.getTables();
|
||||
res.status(200).json(tables);
|
||||
} catch (err) {
|
||||
debug(logger, `GET ${req.path} error: ${err.message}`);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/database/tables/:name
|
||||
* Get table schema and data rows
|
||||
*/
|
||||
router.get("/tables/:name", async (req, res, next) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit, 10) || 50;
|
||||
const offset = parseInt(req.query.offset, 10) || 0;
|
||||
const name = req.params.name;
|
||||
|
||||
const data = await internalDatabase.getTableData(name, limit, offset);
|
||||
res.status(200).json(data);
|
||||
} catch (err) {
|
||||
debug(logger, `GET ${req.path} error: ${err.message}`);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/database/query
|
||||
* Execute a raw SQL query
|
||||
*/
|
||||
router.post("/query", async (req, res, next) => {
|
||||
try {
|
||||
if (!req.body || !req.body.query) {
|
||||
return res.status(400).json({ error: { message: "Query is required" } });
|
||||
}
|
||||
|
||||
const result = await internalDatabase.executeQuery(req.body.query);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
debug(logger, `POST ${req.path} error: ${err.message}`);
|
||||
// We want to return SQL errors cleanly to the UI, not throw a 500 error page
|
||||
res.status(400).json({ error: { message: err.message, sql: true } });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -16,6 +16,7 @@ import tokensRoutes from "./tokens.js";
|
|||
import usersRoutes from "./users.js";
|
||||
import versionRoutes from "./version.js";
|
||||
import wireguardRoutes from "./wireguard.js";
|
||||
import databaseRoutes from "./api/database.js";
|
||||
|
||||
const router = express.Router({
|
||||
caseSensitive: true,
|
||||
|
|
@ -56,6 +57,7 @@ router.use("/nginx/streams", streamsRoutes);
|
|||
router.use("/nginx/access-lists", accessListsRoutes);
|
||||
router.use("/nginx/certificates", certificatesHostsRoutes);
|
||||
router.use("/wireguard", wireguardRoutes);
|
||||
router.use("/database", databaseRoutes);
|
||||
|
||||
/**
|
||||
* API 404 for all other routes
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ router.get("/", async (_req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const ifaces = await internalWireguard.getInterfacesInfo(knex, access);
|
||||
const accessData = await access.can("proxy_hosts:list");
|
||||
const ifaces = await internalWireguard.getInterfacesInfo(knex, access, accessData);
|
||||
res.status(200).json(ifaces);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
|
@ -37,7 +38,8 @@ router.post("/", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const iface = await internalWireguard.createInterface(knex, req.body, access);
|
||||
const accessData = await access.can("proxy_hosts:create");
|
||||
const iface = await internalWireguard.createInterface(knex, req.body, access, accessData);
|
||||
await internalAuditLog.add(access, {
|
||||
action: "created",
|
||||
object_type: "wireguard-server",
|
||||
|
|
@ -58,7 +60,8 @@ router.put("/:id", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const iface = await internalWireguard.updateInterface(knex, req.params.id, req.body, access);
|
||||
const accessData = await access.can("proxy_hosts:update");
|
||||
const iface = await internalWireguard.updateInterface(knex, req.params.id, req.body, access, accessData);
|
||||
await internalAuditLog.add(access, {
|
||||
action: "updated",
|
||||
object_type: "wireguard-server",
|
||||
|
|
@ -79,7 +82,8 @@ router.delete("/:id", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const result = await internalWireguard.deleteInterface(knex, req.params.id, access);
|
||||
const accessData = await access.can("proxy_hosts:delete");
|
||||
const result = await internalWireguard.deleteInterface(knex, req.params.id, access, accessData);
|
||||
await internalAuditLog.add(access, {
|
||||
action: "deleted",
|
||||
object_type: "wireguard-server",
|
||||
|
|
@ -100,7 +104,8 @@ router.post("/:id/links", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const result = await internalWireguard.updateInterfaceLinks(knex, req.params.id, req.body.linked_servers || [], access);
|
||||
const accessData = await access.can("proxy_hosts:update");
|
||||
const result = await internalWireguard.updateInterfaceLinks(knex, req.params.id, req.body.linked_servers || [], access, accessData);
|
||||
await internalAuditLog.add(access, {
|
||||
action: "updated",
|
||||
object_type: "wireguard-server-links",
|
||||
|
|
@ -121,7 +126,8 @@ router.get("/client", async (_req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const clients = await internalWireguard.getClients(knex, access);
|
||||
const accessData = await access.can("proxy_hosts:list");
|
||||
const clients = await internalWireguard.getClients(knex, access, accessData);
|
||||
res.status(200).json(clients);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
|
@ -136,7 +142,8 @@ router.post("/client", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const client = await internalWireguard.createClient(knex, req.body, access);
|
||||
const accessData = await access.can("proxy_hosts:create");
|
||||
const client = await internalWireguard.createClient(knex, req.body, access, accessData);
|
||||
await internalAuditLog.add(access, {
|
||||
action: "created",
|
||||
object_type: "wireguard-client",
|
||||
|
|
@ -157,8 +164,9 @@ router.get("/client/:id", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const accessData = await access.can("proxy_hosts:get");
|
||||
const query = knex("wg_client").where("id", req.params.id);
|
||||
if (!access.token.hasScope("admin")) {
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
|
|
@ -179,7 +187,8 @@ router.put("/client/:id", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const client = await internalWireguard.updateClient(knex, req.params.id, req.body, access);
|
||||
const accessData = await access.can("proxy_hosts:update");
|
||||
const client = await internalWireguard.updateClient(knex, req.params.id, req.body, access, accessData);
|
||||
await internalAuditLog.add(access, {
|
||||
action: "updated",
|
||||
object_type: "wireguard-client",
|
||||
|
|
@ -200,7 +209,8 @@ router.delete("/client/:id", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const result = await internalWireguard.deleteClient(knex, req.params.id, access);
|
||||
const accessData = await access.can("proxy_hosts:delete");
|
||||
const result = await internalWireguard.deleteClient(knex, req.params.id, access, accessData);
|
||||
await internalAuditLog.add(access, {
|
||||
action: "deleted",
|
||||
object_type: "wireguard-client",
|
||||
|
|
@ -221,7 +231,8 @@ router.post("/client/:id/enable", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const client = await internalWireguard.toggleClient(knex, req.params.id, true, access);
|
||||
const accessData = await access.can("proxy_hosts:update");
|
||||
const client = await internalWireguard.toggleClient(knex, req.params.id, true, access, accessData);
|
||||
await internalAuditLog.add(access, {
|
||||
action: "enabled",
|
||||
object_type: "wireguard-client",
|
||||
|
|
@ -242,7 +253,8 @@ router.post("/client/:id/disable", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const client = await internalWireguard.toggleClient(knex, req.params.id, false, access);
|
||||
const accessData = await access.can("proxy_hosts:update");
|
||||
const client = await internalWireguard.toggleClient(knex, req.params.id, false, access, accessData);
|
||||
await internalAuditLog.add(access, {
|
||||
action: "disabled",
|
||||
object_type: "wireguard-client",
|
||||
|
|
@ -263,8 +275,9 @@ router.get("/client/:id/configuration", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const accessData = await access.can("proxy_hosts:get");
|
||||
const query = knex("wg_client").where("id", req.params.id);
|
||||
if (!access.token.hasScope("admin")) {
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
|
|
@ -289,8 +302,9 @@ router.get("/client/:id/qrcode.svg", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const accessData = await access.can("proxy_hosts:get");
|
||||
const query = knex("wg_client").where("id", req.params.id);
|
||||
if (!access.token.hasScope("admin")) {
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
|
|
@ -313,8 +327,9 @@ router.get("/client/:id/configuration.zip", async (req, res, next) => {
|
|||
try {
|
||||
const knex = db();
|
||||
const access = res.locals.access;
|
||||
const accessData = await access.can("proxy_hosts:get");
|
||||
const query = knex("wg_client").where("id", req.params.id);
|
||||
if (!access.token.hasScope("admin")) {
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
}
|
||||
const client = await query.first();
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const RedirectionHosts = lazy(() => import("src/pages/Nginx/RedirectionHosts"));
|
|||
const DeadHosts = lazy(() => import("src/pages/Nginx/DeadHosts"));
|
||||
const Streams = lazy(() => import("src/pages/Nginx/Streams"));
|
||||
const WireGuard = lazy(() => import("src/pages/WireGuard"));
|
||||
const DatabaseManager = lazy(() => import("src/pages/DatabaseManager"));
|
||||
|
||||
function Router() {
|
||||
const health = useHealth();
|
||||
|
|
@ -73,6 +74,7 @@ function Router() {
|
|||
<Route path="/nginx/stream" element={<Streams />} />
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/wireguard" element={<WireGuard />} />
|
||||
<Route path="/database" element={<DatabaseManager />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</SiteContainer>
|
||||
|
|
|
|||
21
frontend/src/api/backend/database.ts
Normal file
21
frontend/src/api/backend/database.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as api from "./base";
|
||||
|
||||
export async function getTables(): Promise<string[]> {
|
||||
return await api.get({
|
||||
url: "/database/tables",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTableData(tableName: string, offset: number = 0, limit: number = 50): Promise<any> {
|
||||
return await api.get({
|
||||
url: `/database/tables/${tableName}`,
|
||||
params: { offset, limit },
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeQuery(query: string): Promise<any> {
|
||||
return await api.post({
|
||||
url: "/database/query",
|
||||
data: { query },
|
||||
});
|
||||
}
|
||||
|
|
@ -62,3 +62,4 @@ export * from "./uploadCertificate";
|
|||
export * from "./validateCertificate";
|
||||
export * from "./twoFactor";
|
||||
export * from "./wireguard";
|
||||
export * from "./database";
|
||||
|
|
|
|||
|
|
@ -102,10 +102,23 @@ const menuItems: MenuItem[] = [
|
|||
permissionSection: ADMIN,
|
||||
},
|
||||
{
|
||||
to: "/settings",
|
||||
icon: IconSettings,
|
||||
label: "settings",
|
||||
label: "tools",
|
||||
permissionSection: ADMIN,
|
||||
items: [
|
||||
{
|
||||
to: "/settings",
|
||||
label: "settings",
|
||||
permissionSection: ADMIN,
|
||||
permission: VIEW,
|
||||
},
|
||||
{
|
||||
to: "/database",
|
||||
label: "database-manager",
|
||||
permissionSection: ADMIN,
|
||||
permission: VIEW,
|
||||
}
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
201
frontend/src/pages/DatabaseManager/index.tsx
Normal file
201
frontend/src/pages/DatabaseManager/index.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { IconDatabase } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Badge, Button, Card, Col, Container, Nav, Row, Table } from "react-bootstrap";
|
||||
import CodeEditor from "@uiw/react-textarea-code-editor";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { executeQuery, getTables, getTableData } from "src/api/backend";
|
||||
import { HasPermission, Loading } from "src/components";
|
||||
import { showError, showSuccess } from "src/notifications";
|
||||
import { ADMIN, VIEW } from "src/modules/Permissions";
|
||||
|
||||
export default function DatabaseManager() {
|
||||
const [activeTab, setActiveTab] = useState<"tables" | "query">("tables");
|
||||
const [activeTable, setActiveTable] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("SELECT * FROM user LIMIT 10;");
|
||||
const [queryResult, setQueryResult] = useState<any>(null);
|
||||
const [queryLoading, setQueryLoading] = useState(false);
|
||||
|
||||
const { data: tables, isLoading: tablesLoading } = useQuery({
|
||||
queryKey: ["database-tables"],
|
||||
queryFn: getTables,
|
||||
});
|
||||
|
||||
const { data: tableData, isLoading: tableDataLoading } = useQuery({
|
||||
queryKey: ["database-table", activeTable, 0, 50],
|
||||
queryFn: () => getTableData(activeTable as string, 0, 50),
|
||||
enabled: !!activeTable && activeTab === "tables",
|
||||
});
|
||||
|
||||
// Select the first table by default when tables are loaded
|
||||
if (tables && tables.length > 0 && !activeTable) {
|
||||
setActiveTable(tables[0]);
|
||||
}
|
||||
|
||||
const handleExecuteQuery = async () => {
|
||||
try {
|
||||
setQueryLoading(true);
|
||||
const res = await executeQuery(query);
|
||||
setQueryResult(res.result);
|
||||
showSuccess("Query executed successfully");
|
||||
} catch (err: any) {
|
||||
showError(err.message || "Failed to execute query");
|
||||
} finally {
|
||||
setQueryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTableData = (data: any[], schema?: any[]) => {
|
||||
if (!data || data.length === 0) return <div className="text-muted p-3">No data</div>;
|
||||
|
||||
const columns = schema ? schema.map((s: any) => s.name || s.Field) : Object.keys(data[0]);
|
||||
|
||||
return (
|
||||
<div className="table-responsive">
|
||||
<Table striped bordered hover size="sm" className="mb-0 text-nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col: string) => (
|
||||
<th key={col}>{col}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row: any, i: number) => (
|
||||
<tr key={i}>
|
||||
{columns.map((col: string) => (
|
||||
<td key={`${i}-${col}`}>
|
||||
{row[col] === null ? (
|
||||
<span className="text-muted">NULL</span>
|
||||
) : typeof row[col] === "object" ? (
|
||||
JSON.stringify(row[col])
|
||||
) : (
|
||||
String(row[col])
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<HasPermission section={ADMIN} permission={VIEW}>
|
||||
<Container className="my-4" fluid>
|
||||
<div className="d-flex align-items-center mb-4">
|
||||
<IconDatabase size={28} className="me-2 text-primary" />
|
||||
<h2 className="mb-0">Database Manager</h2>
|
||||
<Badge bg="danger" className="ms-3 rounded-pill text-uppercase" style={{ letterSpacing: "1px" }}>
|
||||
Super Admin Only
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Nav variant="pills" className="mb-4">
|
||||
<Nav.Item>
|
||||
<Nav.Link
|
||||
active={activeTab === "tables"}
|
||||
onClick={() => setActiveTab("tables")}
|
||||
className="rounded-pill px-4"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Data Viewer
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link
|
||||
active={activeTab === "query"}
|
||||
onClick={() => setActiveTab("query")}
|
||||
className="rounded-pill px-4"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
SQL Editor
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
</Nav>
|
||||
|
||||
{activeTab === "tables" && (
|
||||
<Row>
|
||||
<Col md={3}>
|
||||
<Card className="shadow-sm">
|
||||
<Card.Header className="bg-light fw-bold">Tables</Card.Header>
|
||||
<div className="list-group list-group-flush" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{tablesLoading && <div className="p-3"><Loading /></div>}
|
||||
{tables?.map((table: string) => (
|
||||
<button
|
||||
key={table}
|
||||
className={`list-group-item list-group-item-action ${activeTable === table ? "active" : ""}`}
|
||||
onClick={() => setActiveTable(table)}
|
||||
>
|
||||
{table}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col md={9}>
|
||||
<Card className="shadow-sm">
|
||||
<Card.Header className="bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 className="mb-0 fw-bold">{activeTable || "Select a table"}</h5>
|
||||
{tableData && <Badge bg="secondary">{tableData.total} rows</Badge>}
|
||||
</Card.Header>
|
||||
<Card.Body className="p-0" style={{ maxHeight: "70vh", overflowY: "auto" }}>
|
||||
{tableDataLoading ? (
|
||||
<div className="p-5 d-flex justify-content-center"><Loading /></div>
|
||||
) : (
|
||||
tableData && renderTableData(tableData.rows, tableData.schema)
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{activeTab === "query" && (
|
||||
<Card className="shadow-sm">
|
||||
<Card.Header className="bg-light fw-bold">Execute SQL Query</Card.Header>
|
||||
<Card.Body>
|
||||
<div className="mb-3 border rounded">
|
||||
<CodeEditor
|
||||
value={query}
|
||||
language="sql"
|
||||
placeholder="Please enter SQL code."
|
||||
onChange={(ev) => setQuery(ev.target.value)}
|
||||
padding={15}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
backgroundColor: "#f8f9fa",
|
||||
fontFamily: "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
|
||||
minHeight: "200px"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex justify-content-end mb-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleExecuteQuery}
|
||||
disabled={queryLoading || !query.trim()}
|
||||
>
|
||||
{queryLoading ? <span className="me-2"><Loading /></span> : null}
|
||||
Execute Query
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{queryResult && (
|
||||
<div className="mt-4">
|
||||
<h5 className="mb-3 border-bottom pb-2">Results</h5>
|
||||
{Array.isArray(queryResult) ? renderTableData(queryResult) : (
|
||||
<pre className="p-3 bg-light rounded border">
|
||||
{JSON.stringify(queryResult, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
)}
|
||||
</Container>
|
||||
</HasPermission>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue