diff --git a/CMakeLists.txt b/CMakeLists.txt index 22cb409f..21da0d2f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -127,7 +127,7 @@ endif() message(STATUS "Variable: DLU_CONFIG_DIR = ${DLU_CONFIG_DIR}") # Copy resource files on first build -set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "blocklist.dcf") +set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "dashboardconfig.ini" "worldconfig.ini" "masterconfig.ini" "blocklist.dcf") message(STATUS "Checking resource file integrity") include(Utils) diff --git a/dDashboardServer/CMakeLists.txt b/dDashboardServer/CMakeLists.txt index 436e9c91..37589b79 100644 --- a/dDashboardServer/CMakeLists.txt +++ b/dDashboardServer/CMakeLists.txt @@ -1,8 +1,55 @@ +add_subdirectory(blueprints) + set(DDASHBOARDSERVER_SOURCES "DashboardWeb.cpp" + # Explicitly include blueprint sources to ensure they are compiled into the library + "blueprints/AuthBlueprint.cpp" + "blueprints/ApiBlueprint.cpp" + "blueprints/PageBlueprint.cpp" + "blueprints/PlayKeysBlueprint.cpp" + "blueprints/CharactersBlueprint.cpp" + "blueprints/MailBlueprint.cpp" + "blueprints/BugReportsBlueprint.cpp" + "blueprints/ModerationBlueprint.cpp" ) -add_executable(DashboardServer "DashboardServer.cpp" "DashboardWeb.cpp") -target_link_libraries(DashboardServer ${COMMON_LIBRARIES} dServer dWeb) -target_include_directories(DashboardServer PRIVATE ${PROJECT_SOURCE_DIR}/dServer ${PROJECT_SOURCE_DIR}/dWeb) +# Create dDashboardServer library +add_library(dDashboardServer ${DDASHBOARDSERVER_SOURCES}) +target_include_directories(dDashboardServer PRIVATE ${PROJECT_SOURCE_DIR}/dServer) +find_package(CURL) +if (CURL_FOUND) + target_link_libraries(dDashboardServer ${COMMON_LIBRARIES} dServer Crow::Crow bcrypt CURL::libcurl) +else() + message(WARNING "libcurl not found; building dDashboardServer without CURL::libcurl. Some features may be disabled.") + target_link_libraries(dDashboardServer ${COMMON_LIBRARIES} dServer Crow::Crow bcrypt) +endif() + +add_executable(DashboardServer "DashboardServer.cpp") +if (CURL_FOUND) + target_link_libraries(DashboardServer ${COMMON_LIBRARIES} dServer Crow::Crow bcrypt CURL::libcurl dDashboardServer) +else() + target_link_libraries(DashboardServer ${COMMON_LIBRARIES} dServer Crow::Crow bcrypt dDashboardServer) +endif() +target_include_directories(DashboardServer PRIVATE ${PROJECT_SOURCE_DIR}/dServer) add_compile_definitions(DashboardServer PRIVATE PROJECT_VERSION="\"${PROJECT_VERSION}\"") + +# Define Windows version for ASIO/Crow compatibility (Windows 10) +if(WIN32) + target_compile_definitions(DashboardServer PRIVATE _WIN32_WINNT=0x0A00) + target_compile_definitions(dDashboardServer PRIVATE _WIN32_WINNT=0x0A00) +endif() + +# Copy static files and templates to build directory +add_custom_command(TARGET DashboardServer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/static + $/static + COMMENT "Copying static files to build directory" +) + +add_custom_command(TARGET DashboardServer POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_CURRENT_SOURCE_DIR}/templates + $/templates + COMMENT "Copying templates to build directory" +) diff --git a/dDashboardServer/DashboardHelpers.cpp b/dDashboardServer/DashboardHelpers.cpp new file mode 100644 index 00000000..73280804 --- /dev/null +++ b/dDashboardServer/DashboardHelpers.cpp @@ -0,0 +1,33 @@ +#include "DashboardHelpers.h" + +namespace DashboardHelpers { + +DataTablesParams ParseDataTablesParams(const crow::request& req) { + DataTablesParams p; + try { + if (req.url_params.get("draw")) p.draw = std::stoi(req.url_params.get("draw")); + if (req.url_params.get("start")) p.start = std::stoi(req.url_params.get("start")); + if (req.url_params.get("length")) p.length = std::stoi(req.url_params.get("length")); + if (req.url_params.get("order[0][column]")) p.orderColumn = std::stoi(req.url_params.get("order[0][column]")); + if (req.url_params.get("order[0][dir]")) p.orderDir = req.url_params.get("order[0][dir]"); + } catch (...) { + // ignore parse errors, return defaults + } + return p; +} + +crow::json::wvalue CreateDataTablesResponse(int draw, uint32_t recordsTotal, uint32_t recordsFiltered, const crow::json::wvalue::list& data) { + crow::json::wvalue resp; + resp["draw"] = draw; + resp["recordsTotal"] = recordsTotal; + resp["recordsFiltered"] = recordsFiltered; + resp["data"] = data; + return resp; +} + +bool RescueCharacter(const uint64_t characterId, const uint32_t zoneId) { + // Minimal stub: not implemented here. Return false to indicate no-op. + return false; +} + +} // namespace DashboardHelpers diff --git a/dDashboardServer/DashboardHelpers.h b/dDashboardServer/DashboardHelpers.h new file mode 100644 index 00000000..ff02e76e --- /dev/null +++ b/dDashboardServer/DashboardHelpers.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include + +namespace DashboardHelpers { + +struct DataTablesParams { + int draw{0}; + int start{0}; + int length{10}; + int orderColumn{-1}; + std::string orderDir{"asc"}; +}; + +// Parse common DataTables GET params from the request +DataTablesParams ParseDataTablesParams(const crow::request& req); + +// Create a DataTables response object +crow::json::wvalue CreateDataTablesResponse(int draw, uint32_t recordsTotal, uint32_t recordsFiltered, const crow::json::wvalue::list& data); + +// Rescue character stub (real logic may be project-specific) +bool RescueCharacter(const uint64_t characterId, const uint32_t zoneId); + +} diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index d37bbba5..47cba255 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -1,3 +1,6 @@ +#ifndef PROJECT_VERSION +#define PROJECT_VERSION "dev" +#endif #include #include #include @@ -22,7 +25,10 @@ #include "RakNetDefines.h" #include "MessageIdentifiers.h" +#include "MessageType/Server.h" + #include "DashboardWeb.h" +#include "DashboardShared.h" namespace Game { Logger* logger = nullptr; @@ -33,6 +39,9 @@ namespace Game { std::mt19937 randomEngine; } +// Forward declaration +void HandlePacket(Packet* packet); + int main(int argc, char** argv) { constexpr uint32_t dashboardFramerate = mediumFramerate; constexpr uint32_t dashboardFrameDelta = mediumFrameDelta; @@ -83,18 +92,9 @@ int main(int argc, char** argv) { return EXIT_FAILURE; } - // setup the chat api web server - const uint32_t web_server_port = GeneralUtils::TryParse(Game::config->GetValue("web_server_port")).value_or(80); - if (!Game::web.Startup("localhost", web_server_port)) { - // if we want the web server and it fails to start, exit - LOG("Failed to start web server, shutting down."); - Database::Destroy("DashboardServer"); - delete Game::logger; - delete Game::config; - return EXIT_FAILURE; - } - - DashboardWeb::RegisterRoutes(); + // Setup and start the Crow web server (runs in its own thread) + const uint32_t web_server_port = GeneralUtils::TryParse(Game::config->GetValue("web_server_port")).value_or(8080); + DashboardWeb::Initialize(web_server_port); //Find out the master's IP: std::string masterIP; @@ -116,6 +116,9 @@ int main(int argc, char** argv) { Game::server = new dServer(ourIP, ourPort, 0, maxClients, false, true, Game::logger, masterIP, masterPort, ServiceType::COMMON, Game::config, &Game::lastSignal, masterPassword); + // Update shared state with master server info + DashboardShared::g_Stats.SetMasterInfo(masterIP, masterPort); + Game::randomEngine = std::mt19937(time(0)); //Run it until server gets a kill message from Master: @@ -128,6 +131,7 @@ int main(int argc, char** argv) { uint32_t framesSinceLastSQLPing = 0; auto lastTime = std::chrono::high_resolution_clock::now(); + auto startTime = lastTime; // Track server start time for uptime Game::logger->Flush(); // once immediately before main loop while (!Game::ShouldShutdown()) { @@ -137,14 +141,43 @@ int main(int argc, char** argv) { if (framesSinceMasterDisconnect >= dashboardFramerate) break; //Exit our loop, shut down. - } else framesSinceMasterDisconnect = 0; + + DashboardShared::SetMasterConnected(false); + } else { + framesSinceMasterDisconnect = 0; + DashboardShared::SetMasterConnected(true); + } const auto currentTime = std::chrono::high_resolution_clock::now(); const float deltaTime = std::chrono::duration(currentTime - lastTime).count(); lastTime = currentTime; - // Check and handle web requests: - Game::web.ReceiveRequests(); + // Check for packets from master: + Game::server->ReceiveFromMaster(); + + // Process queued packet sends from Crow threads + if (DashboardShared::g_PacketQueue.HasPending()) { + auto pendingPackets = DashboardShared::g_PacketQueue.DequeueAll(); + for (const auto& request : pendingPackets) { + // Create BitStream from queued data + RakNet::BitStream bitStream(const_cast(request.data.data()), request.data.size(), false); + + // Send via RakNet (safe - we're in the RakNet thread) + Game::server->Send(bitStream, request.target, request.broadcast); + DashboardShared::OnPacketSent(); + + LOG("Sent queued packet from web request (%zu bytes)", request.data.size()); + } + } + + // Check for RakNet packets: + packet = Game::server->Receive(); + if (packet) { + HandlePacket(packet); + DashboardShared::OnPacketReceived(); // Update shared stats + Game::server->DeallocatePacket(packet); + packet = nullptr; + } //Push our log every 30s: if (framesSinceLastFlush >= logFlushTime) { @@ -167,11 +200,14 @@ int main(int argc, char** argv) { framesSinceLastSQLPing = 0; } else framesSinceLastSQLPing++; - //Sleep our thread since auth can afford to. - t += std::chrono::milliseconds(dashboardFrameDelta); //Chat can run at a lower "fps" + //Sleep our thread since dashboard can afford to. + t += std::chrono::milliseconds(dashboardFrameDelta); std::this_thread::sleep_until(t); } + // Stop the Crow web server + DashboardWeb::Stop(); + //Delete our objects here: Database::Destroy("DashboardServer"); delete Game::server; @@ -180,3 +216,33 @@ int main(int argc, char** argv) { return EXIT_SUCCESS; } + +void HandlePacket(Packet* packet) { + if (packet->length < 4) return; + + if (packet->data[0] == ID_DISCONNECTION_NOTIFICATION || packet->data[0] == ID_CONNECTION_LOST) { + LOG("A client has disconnected"); + DashboardShared::OnClientDisconnected(); + return; + } + + if (packet->data[0] == ID_NEW_INCOMING_CONNECTION) { + LOG("New incoming connection from %s", packet->systemAddress.ToString()); + DashboardShared::OnClientConnected(); + return; + } + + if (packet->data[0] != ID_USER_PACKET_ENUM) return; + + // Handle server packets + if (static_cast(packet->data[1]) == ServiceType::COMMON) { + if (static_cast(packet->data[3]) == MessageType::Server::VERSION_CONFIRM) { + LOG("Version confirmation received from client"); + DashboardShared::OnPacketReceived("VERSION_CONFIRM"); + } + } + + // Add more packet handling as needed + // This is where you would handle custom dashboard-specific packets + // All packet handling can safely update DashboardShared state +} diff --git a/dDashboardServer/DashboardShared.h b/dDashboardServer/DashboardShared.h new file mode 100644 index 00000000..79b330a4 --- /dev/null +++ b/dDashboardServer/DashboardShared.h @@ -0,0 +1,187 @@ +#ifndef __DASHBOARDSHARED_H__ +#define __DASHBOARDSHARED_H__ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "dCommonVars.h" +#include "RakNetTypes.h" +#include "GameDatabase.h" +#include "crow.h" + +// Forward declaration +class GameDatabase; +namespace RakNet { + class BitStream; +}; + +/** + * Shared state between the Crow web server (runs in background threads) + * and the RakNet game loop (runs in main thread). + * + * All members use thread-safe types (atomic, mutex-protected) + * + * IMPORTANT: RakNet is NOT thread-safe! + * - Crow threads can READ state and QUEUE packet send requests + * - Only the RakNet thread (main loop) can actually send packets + */ +namespace DashboardShared { + + // ===== Atomic Counters (lock-free, safe for simple reads/writes) ===== + + inline std::atomic g_ConnectedClients{0}; + inline std::atomic g_ConnectedToMaster{false}; + inline std::atomic g_PacketsReceived{0}; + inline std::atomic g_PacketsSent{0}; + + // ===== Mutex-Protected Data (for complex structures) ===== + + struct ServerStats { + std::mutex mutex; + uint64_t uptime_seconds = 0; + std::string last_packet_type; + uint32_t raknet_port = 0; + std::string master_ip; + + // Thread-safe getters + uint64_t GetUptime() { + std::lock_guard lock(mutex); + return uptime_seconds; + } + + std::string GetLastPacketType() { + std::lock_guard lock(mutex); + return last_packet_type; + } + + void SetLastPacketType(const std::string& type) { + std::lock_guard lock(mutex); + last_packet_type = type; + } + + void SetMasterInfo(const std::string& ip, uint32_t port) { + std::lock_guard lock(mutex); + master_ip = ip; + raknet_port = port; + } + }; + + inline ServerStats g_Stats; + + // ===== Packet Send Queue (for Crow -> RakNet communication) ===== + + /** + * Represents a packet send request from Crow to RakNet. + * Crow threads add to the queue, RakNet thread processes them. + */ + struct PacketSendRequest { + std::vector data; // Packet data (owns the memory) + SystemAddress target; // Target address (or UNASSIGNED for broadcast) + bool broadcast; // Whether to broadcast + + PacketSendRequest(const std::vector& packetData, + const SystemAddress& addr, + bool isBroadcast) + : data(packetData), target(addr), broadcast(isBroadcast) {} + }; + + // Thread-safe queue of packet send requests + struct PacketQueue { + std::mutex mutex; + std::queue queue; + + // Called from Crow threads to queue a packet for sending + void Enqueue(const std::vector& data, const SystemAddress& addr, bool broadcast) { + std::lock_guard lock(mutex); + queue.emplace(data, addr, broadcast); + } + + // Called from RakNet thread to get all pending packets + std::vector DequeueAll() { + std::lock_guard lock(mutex); + std::vector result; + while (!queue.empty()) { + result.push_back(std::move(queue.front())); + queue.pop(); + } + return result; + } + + // Check if queue has pending packets + bool HasPending() { + std::lock_guard lock(mutex); + return !queue.empty(); + } + }; + + inline PacketQueue g_PacketQueue; + + // ===== Helper Functions ===== + + // Called from RakNet thread when a client connects + inline void OnClientConnected() { + g_ConnectedClients++; + } + + // Called from RakNet thread when a client disconnects + inline void OnClientDisconnected() { + if (g_ConnectedClients > 0) { + g_ConnectedClients--; + } + } + + // Called from RakNet thread when master connection status changes + inline void SetMasterConnected(bool connected) { + g_ConnectedToMaster = connected; + } + + // Called from RakNet thread when a packet is processed + inline void OnPacketReceived(const std::string& packetType = "") { + g_PacketsReceived++; + if (!packetType.empty()) { + g_Stats.SetLastPacketType(packetType); + } + } + + // Called from RakNet thread when a packet is sent + inline void OnPacketSent() { + g_PacketsSent++; + } + + // ===== Crow -> RakNet Communication ===== + + /** + * Queue a RakNet packet to be sent (called from Crow threads). + * The packet will be sent on the next RakNet thread update. + * + * @param data Packet data to send + * @param target Target system address (use UNASSIGNED_SYSTEM_ADDRESS for broadcast) + * @param broadcast Whether to broadcast to all connected clients + */ + inline void QueuePacketSend(const std::vector& data, + const SystemAddress& target = UNASSIGNED_SYSTEM_ADDRESS, + bool broadcast = false) { + g_PacketQueue.Enqueue(data, target, broadcast); + } + + /** + * Helper to queue a BitStream for sending (called from Crow threads). + * Converts BitStream to raw data and queues it. + */ + inline void QueueBitStreamSend(RakNet::BitStream& bitStream, + const SystemAddress& target = UNASSIGNED_SYSTEM_ADDRESS, + bool broadcast = false) { + std::vector data(bitStream.GetData(), + bitStream.GetData() + bitStream.GetNumberOfBytesUsed()); + QueuePacketSend(data, target, broadcast); + } +} +#endif // __DASHBOARDSHARED_H__ diff --git a/dDashboardServer/DashboardWeb.cpp b/dDashboardServer/DashboardWeb.cpp index a6e9eca7..c6f45e93 100644 --- a/dDashboardServer/DashboardWeb.cpp +++ b/dDashboardServer/DashboardWeb.cpp @@ -1,59 +1,153 @@ #include "DashboardWeb.h" +#include "DashboardShared.h" + +// Blueprint includes +#include "blueprints/AuthBlueprint.h" +#include "blueprints/ApiBlueprint.h" +#include "blueprints/PageBlueprint.h" +#include "blueprints/PlayKeysBlueprint.h" +#include "blueprints/CharactersBlueprint.h" +#include "blueprints/MailBlueprint.h" +#include "blueprints/BugReportsBlueprint.h" +#include "blueprints/ModerationBlueprint.h" + +// Crow headers - must come before ASIO to avoid conflicts +#include "crow.h" +#include "crow/middlewares/session.h" // thanks bill gates #ifdef _WIN32 #undef min #undef max #endif -#include "inja.hpp" -#include "eHTTPMethod.h" - - -// simple home page with inja -void HandleHTTPHomeRequest(HTTPReply& reply, std::string body) { - try { - inja::Environment env; - env.set_trim_blocks(true); - env.set_lstrip_blocks(true); - - nlohmann::json data; - data["title"] = "Darkflame Universe Dashboard"; - data["header"] = "Welcome to the Darkflame Universe Dashboard"; - data["message"] = "This is a simple dashboard page served using Inja templating engine."; - - const std::string template_str = R"( - - - - {{ title }} - - - - -

{{ header }}

-

{{ message }}

- - - )"; - - std::string rendered = env.render(template_str, data); - reply.message = rendered; - reply.status = eHTTPStatusCode::OK; - reply.contentType = ContentType::HTML; - } catch (const std::exception& e) { - reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR; - reply.message = "Internal Server Error"; - reply.contentType = ContentType::PLAIN; - } -} +#include +#include +#include +#include namespace DashboardWeb { - void RegisterRoutes() { - Game::web.RegisterHTTPRoute({ - .path = "/", - .method = eHTTPMethod::GET, - .handle = HandleHTTPHomeRequest - }); + + using Session = crow::SessionMiddleware; + + static crow::App g_App { + Session{ + // cookie config: use "session" cookie name, 24h max_age + crow::CookieParser::Cookie("session").max_age(24 * 60 * 60).path("/"), + // session id length + 32, + // storage backend (InMemoryStore) + crow::InMemoryStore{} + } + }; + + static std::future g_ServerFuture; + static bool g_Running = false; + static bool g_Initialized = false; + + void SetupRoutes() { + static bool setupCalled = false; + if (setupCalled) { + std::cerr << "WARNING: SetupRoutes() called multiple times!" << std::endl; + return; + } + setupCalled = true; + + std::cerr << "Setting up dashboard routes..." << std::endl; + + // Set mustache template base directory + crow::mustache::set_base("./templates"); + + // Setup all blueprint routes + try { + std::cerr << " - Setting up AuthBlueprint..." << std::endl; + AuthBlueprint::Setup(g_App); + + std::cerr << " - Setting up ApiBlueprint..." << std::endl; + ApiBlueprint::Setup(g_App); + + std::cerr << " - Setting up PageBlueprint..." << std::endl; + PageBlueprint::Setup(g_App); + + std::cerr << " - Setting up PlayKeysBlueprint..." << std::endl; + PlayKeysBlueprint::Setup(g_App); + + std::cerr << " - Setting up CharactersBlueprint..." << std::endl; + CharactersBlueprint::Setup(g_App); + + std::cerr << " - Setting up MailBlueprint..." << std::endl; + MailBlueprint::Setup(g_App); + + std::cerr << " - Setting up BugReportsBlueprint..." << std::endl; + BugReportsBlueprint::Setup(g_App); + + std::cerr << " - Setting up ModerationBlueprint..." << std::endl; + ModerationBlueprint::Setup(g_App); + + std::cerr << "All routes set up successfully!" << std::endl; + } catch (const std::exception& e) { + // Print to stderr since LOG might not be available + std::cerr << "Error setting up routes: " << e.what() << std::endl; + throw; + } } -} + + void Initialize(uint32_t port) { + // Only allow initialization once per process lifetime + // Crow apps cannot be restarted once stopped + if (g_Initialized) { + std::cerr << "Dashboard web server already initialized. Cannot reinitialize." << std::endl; + return; + } + + try { + // Setup routes (only happens once) + SetupRoutes(); + + // Configure Crow app + g_App.loglevel(crow::LogLevel::Info); // Changed to Info to see startup messages + + // Start the server in a separate thread + g_ServerFuture = std::async(std::launch::async, [port]() { + try { + g_App.port(port).multithreaded().run(); + } catch (const std::exception& e) { + std::cerr << "Error running Crow server: " << e.what() << std::endl; + } + }); + + g_Running = true; + g_Initialized = true; + + // Give the server a moment to start + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + } catch (const std::exception& e) { + std::cerr << "Error initializing dashboard web server: " << e.what() << std::endl; + throw; + } + } + + void Update() { + // Crow runs in its own thread, nothing to update here + } + + void Stop() { + if (!g_Running) { + return; + } + + g_App.stop(); + + // Wait for the server thread to finish (with timeout) + if (g_ServerFuture.valid()) { + auto status = g_ServerFuture.wait_for(std::chrono::seconds(5)); + if (status == std::future_status::timeout) { + std::cerr << "Warning: Dashboard web server did not stop gracefully" << std::endl; + } + } + + g_Running = false; + } + +} // namespace DashboardWeb diff --git a/dDashboardServer/DashboardWeb.h b/dDashboardServer/DashboardWeb.h index 7cf9d731..5634e4db 100644 --- a/dDashboardServer/DashboardWeb.h +++ b/dDashboardServer/DashboardWeb.h @@ -1,11 +1,19 @@ #ifndef __DASHBOARDWEB_H__ #define __DASHBOARDWEB_H__ -#include "Web.h" +#include +#include namespace DashboardWeb { - void RegisterRoutes(); + + // Initialize the web server and configure routes using blueprints + void Initialize(uint32_t port); + + // Process pending web requests (call each frame/tick) + void Update(); + + // Stop the web server + void Stop(); }; - #endif // __DASHBOARDWEB_H__ diff --git a/dDashboardServer/better-templates/base.mustache b/dDashboardServer/better-templates/base.mustache new file mode 100644 index 00000000..73fd0b10 --- /dev/null +++ b/dDashboardServer/better-templates/base.mustache @@ -0,0 +1,143 @@ + + + + + + + {{#title}}{{title}}{{/title}}{{^title}}Dashboard{{/title}} - {{config.APP_NAME}} + + + + {{! CSS }} + + + + + + + + + + + + + + + + + + {{> header}} + + + +
+ + +
+
{{content_before}}

+
+ + + {! TODO: make this dynamic toasts !!} + {{#messages}} + + {{/messages}} + +
+ +
+ {{content}} +
+ +
+ {{content_after}} +
+ +
+ {{#footer}} +
+ {{/footer}} +
+ + {{! JS assets }} + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dDashboardServer/better-templates/header.mustache b/dDashboardServer/better-templates/header.mustache new file mode 100644 index 00000000..0b93e7b1 --- /dev/null +++ b/dDashboardServer/better-templates/header.mustache @@ -0,0 +1,113 @@ +{{! Navigation brand, nav toggle bar }} +