Added relations to redis ORM

This commit is contained in:
2024-08-13 15:18:47 -04:00
parent 2a5ca3664a
commit b27e1b1617
20 changed files with 240 additions and 150 deletions
+3 -2
View File
@@ -1,7 +1,7 @@
'use strict';
const {User} = require('../models/user');
const {AuthToken} = require('../models/token');
const Table = require('../models');
const {User, AuthToken} = Table.models;
class Auth{
@@ -35,6 +35,7 @@ class Auth{
throw this.errors.login();
}catch(error){
console.log('check error', error);
throw this.errors.login();
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ const {Auth} = require('../controller/auth');
async function auth(req, res, next){
try{
req.token = await Auth.checkToken(req.header('auth-token'));
req.user = await req.token.getUser();
req.user = req.token.user;
return next();
}catch(error){
next(error);
@@ -15,7 +15,7 @@ async function auth(req, res, next){
async function authIO(socket, next){
try{
let token = await Auth.checkToken(socket.handshake.auth.token || 0);
socket.user = await token.getUser();
socket.user = token.user;
next();
}catch(error){
next(error);
@@ -9,9 +9,9 @@ const ModelPs = require('../utils/model_pubsub');
const tldExtract = require('tld-extract').parse_host;
const providers = {
PorkBun: require('./dns_provider/porkbun'),
DigitalOcean: require('./dns_provider/digitalocean'),
Cloudflare: require('./dns_provider/cloudflare'),
DigitalOcean: require('./dns_provider/digitalocean'),
PorkBun: require('./dns_provider/porkbun'),
};
class Domain extends Table{
@@ -23,17 +23,22 @@ class Domain extends Table{
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'domain': {isRequired: true, type: 'string'},
'dnsProvider_id': {isRequired: true, type: 'string'},
'provider': {model: 'DnsProvider', rel:'one', localKey: 'dnsProvider_id'},
'zoneId': {isRequired: false, type: 'string'},
}
static async get(data, ...args){
let instance = await super.get(data, ...args);
// instance.provider = (await DnsProvider.get(instance.dnsProvider_id)).provider;
static async get(domain, ...args){
try{
domain = tldExtract(domain).domain;
}catch{}
let instance = await super.get(domain, ...args);
return instance;
}
static async getByProviderId(id){
let domains = await this.listDetail();
static async getByProviderId(id, ...args){
let domains = await this.listDetail(...args);
let results = [];
for(let domain of domains){
if(domain.dnsProvider_id == id) results.push(domain);
@@ -41,8 +46,21 @@ class Domain extends Table{
return results;
}
// toJSON(){
// return {
// ...super.toJSON(),
// provider: {
// displayName: this.provider.constructor.displayName,
// displayIconUni: this.provider.constructor.displayIconUni,
// displayIconHtml: this.provider.constructor.displayIconHtml,
// }
// }
// }
}
Domain.register(ModelPs(Domain))
class DnsProvider extends Table{
static _key = 'id';
static _keyMap = {
@@ -53,7 +71,7 @@ class DnsProvider extends Table{
'id': {default: ()=>crypto.randomBytes(8).toString("hex")},
'name': {isRequired: true, type: 'string'},
'dnsProvider': {isRequired: true, type: 'string'},
'zoneId': {isRequired: false, type: 'string'},
'domains': {model:'Domain', rel: 'many', remoteKey: 'dnsProvider_id'}
}
static __intraModel(provider){
@@ -74,23 +92,16 @@ class DnsProvider extends Table{
static async create(data, ...args){
let __intraModel = this.__intraModel(data.dnsProvider)
let provider = new __intraModel.Provider(data);
let res = await provider.listDomains();
let provider = new __intraModel.Provider(data, ...args);
let instance = await super.create.call(__intraModel, data);
if(data.noUpdate !== false) await instance.updateDomains();
return instance;
return await super.create.call(__intraModel, data, ...args);
}
static async get(data, ...args){
let instance = await super.get(data);
let instance = await super.get(data, ...args);
let __intraModel = this.__intraModel(instance.dnsProvider)
instance = await super.get.call(__intraModel, data);
instance.provider = new __intraModel.Provider(instance);
return instance;
return await super.get.call(__intraModel, data, ...args);
}
static listProviders(){
@@ -138,24 +149,33 @@ class DnsProvider extends Table{
toJSON(){
return {
...super.toJSON(),
displayName: this.provider.constructor.displayName,
displayIconHtml: this.provider.constructor.displayIconHtml,
displayIconUni: this.provider.constructor.displayIconUni,
...this.constructor.Provider.toJSON()
};
}
}
Domain = ModelPs(Domain);
DnsProvider = ModelPs(DnsProvider);
DnsProvider.register(ModelPs(DnsProvider))
module.exports = {Domain, DnsProvider};
if(require.main === module){(async function(){try{
const conf = require('../conf');
console.log(Table.models)
// console.log(await Domain.listDetail())
// console.log(JSON.stringify(await Domain.get('ipa.wtf'), null, 2))
// console.log(JSON.stringify(await Domain.listDetail() ,null, 2))
console.log(JSON.stringify(await Table.models.DnsProvider.listDetail() ,null, 2))
// console.log(await Domain.getByProviderId('e8443e03ac503c7b'));
// console.log(aw)
process.exit(0)
}catch(error){
console.log('IIFE Error:', error);
}})()}
+7 -6
View File
@@ -1,28 +1,29 @@
'use strict';
const axios = require('axios');
const {dnsErrors} = require('./common');
const {dnsErrors, DnsApi} = require('./common');
//like the options obj will always use domain data and type
// change content to data
// change name to domain
class CloudFlare{
class CloudFlare extends DnsApi{
static _keyMap = {
token: {isRequired: true, type: 'string', isPrivate: true, displayName: 'API Token'},
}
static displayName = 'CloudFlare'
static displayIconHtml = `
<svg width="32px" height="32px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#0080ff"/>
<path d="m273.8 669.2-.1-63.7h63.7v63.7h76v-98.8h98.8v98.5c105.1-.1 186.2-104.1 146.1-214.6-14.9-40.9-47.6-73.6-88.5-88.4-110.7-40.2-214.7 41.2-214.7 146.3H256c0-167.5 161.8-298 337.4-243.2 76.8 24 137.7 84.9 161.6 161.6C809.9 606.2 679.4 768 511.9 768v-98.8h-98.6v75.9h-75.9v-75.9h-63.6z" style="fill:#fff"/>
</svg>`
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#f38020"/>
<path d="M608.2 592.4c3.1-10.8 1.9-20.7-3.3-28.1-4.8-6.7-12.9-10.6-22.6-11.1l-184.7-2.4c-1.1 0-2.2-.6-2.8-1.5-.6-.9-.7-2.1-.4-3.3.6-1.8 2.4-3.2 4.3-3.3l186.4-2.4c22.1-1 46.1-18.9 54.5-40.8l10.6-27.8c.5-1.2.6-2.4.3-3.6-12-54.3-60.5-94.8-118.4-94.8-53.4 0-98.7 34.5-114.9 82.4-10.5-7.8-23.9-12-38.3-10.6-25.7 2.5-46.2 23.1-48.8 48.8-.6 6.6-.1 13.1 1.4 19.1-41.9 1.2-75.3 35.4-75.3 77.6 0 3.7.3 7.5.8 11.2.3 1.8 1.8 3.1 3.6 3.1h340.9c1.9 0 3.8-1.4 4.3-3.3l2.4-9.2zM667 473.7c-1.6 0-3.4 0-5.1.2-1.2 0-2.2.9-2.7 2.1l-7.2 25c-3.1 10.8-2 20.7 3.3 28.1 4.8 6.7 12.9 10.6 22.7 11.1l39.3 2.4c1.2 0 2.3.6 2.8 1.5.6.9.7 2.3.5 3.3-.6 1.8-2.4 3.2-4.4 3.3l-41 2.4c-22.2 1-46 18.9-54.5 40.8l-3 7.6c-.6 1.5.5 3 2.1 3h140.8c1.6 0 3.1-1 3.6-2.7 2.4-8.7 3.7-17.9 3.7-27.3 0-55.5-45.3-100.8-101-100.8" style="fill:#fff"/>
</svg>`
// Cloud icon for cloudflare
static displayIconUni = '&#xf0c2;'
constructor(token){
super()
this.token = token.token || token;
}
+46 -1
View File
@@ -19,7 +19,52 @@ let dnsErrors = {
},
}
class DnsApi{
errors = {
unauthorized(instance){
let error = new Error('UnauthorizedDnsApi');
error.name = 'UnauthorizedDnsApi';
error.message = `Unauthorized call to ${instance.constructor ? instance.constructor.name : instance.name}`;
error.status = 424;
return error;
},
other(instance, status, message){
let error = new Error('OtherDnsApiError');
error.name = 'OtherDnsApiError';
error.message = `DNS API Error ${instance.constructor ? instance.constructor.name : instance.name}: `;
error.status = 424;
return error;
},
}
static info(){
let svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(this.displayIconHtml)
.replace(/'/g, '%27')
.replace(/"/g, '%22')}`
return {
displayName: this.displayName,
displayIconUni: this.displayIconUni,
displayIconHtml: svgDataUrl,
fields: this._keyMap,
}
}
static toJSON(){
return {
...this.info(),
}
}
toJSON(){
return this.constructor.toJSON()
}
}
module.exports = {
dnsErrors
dnsErrors,
DnsApi,
};
+4 -3
View File
@@ -1,16 +1,16 @@
'use strict';
const axios = require('axios');
const {dnsErrors} = require('./common');
const {dnsErrors, DnsApi} = require('./common');
class DigitalOcean{
class DigitalOcean extends DnsApi{
static _keyMap = {
token: {isRequired: true, type: 'string', isPrivate: true, displayName: 'API Token'},
}
static displayName = 'DigitalOcean'
static displayIconHtml = `
<svg width="32px" height="32px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<svg height="100%" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<circle cx="512" cy="512" r="512" style="fill:#0080ff"/>
<path d="m273.8 669.2-.1-63.7h63.7v63.7h76v-98.8h98.8v98.5c105.1-.1 186.2-104.1 146.1-214.6-14.9-40.9-47.6-73.6-88.5-88.4-110.7-40.2-214.7 41.2-214.7 146.3H256c0-167.5 161.8-298 337.4-243.2 76.8 24 137.7 84.9 161.6 161.6C809.9 606.2 679.4 768 511.9 768v-98.8h-98.6v75.9h-75.9v-75.9h-63.6z" style="fill:#fff"/>
</svg>`
@@ -18,6 +18,7 @@ class DigitalOcean{
static displayIconUni = '&#xf391;'
constructor(token){
super()
this.token = token.token || token;
}
+7 -9
View File
@@ -1,18 +1,19 @@
'use strict';
const axios = require('axios');
const {dnsErrors} = require('./common');
const {dnsErrors, DnsApi} = require('./common');
class PorkBun{
class PorkBun extends DnsApi{
static _keyMap = {
'apiKey': {isRequired: true, type: 'string', isPrivate: true, displayName: 'API key'},
'secretApiKey': {isRequired: true, type: 'string', isPrivate: true, displayName: 'API Secret key'},
}
static displayName = 'DigitalOcean';
static displayName = 'PorkBun';
static displayIconUni = '&#xf7e5';
static displayIconHtml = `
<svg width="32px" height="32px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<style>
.st1{fill:#fff}
</style>
@@ -24,12 +25,9 @@ class PorkBun{
</g>
</g>
</svg>`
// '<i class="fa-solid fa-bacon"></i>';
static displayIconUni = '&#xf7e5';
baseUrl = 'https://api.porkbun.com/api/json/v3';
constructor(args){
super()
this.apiKey = args.apiKey;
this.secretApiKey = args.secretApiKey;
}
@@ -42,7 +40,7 @@ class PorkBun{
secretapikey: this.secretApiKey,
apikey: this.apiKey,
};
res = await axios.post(`${this.baseUrl}${url}`, data);
res = await axios.post(`'https://api.porkbun.com/api/json/v3'${url}`, data);
return res;
}catch(error){
+3 -7
View File
@@ -1,18 +1,12 @@
'use strict';
const Table = require('../utils/redis_model');
const Table = require('.');
const ModelPs = require('../utils/model_pubsub');
const tldExtract = require('tld-extract').parse_host;
const PorkBun = require('./dns_provider/porkbun');
const LetsEncrypt = require('../utils/letsencrypt');
const conf = require('../conf');
// let porkBun = new PorkBun(conf.porkBun.apiKey, conf.porkBun.secretApiKey);
// let letsEncrypt = new LetsEncrypt({
// directoryUrl: LetsEncrypt.AcmeClient.directory.letsencrypt.staging,
// });
class Host extends Table{
static _key = 'host';
@@ -31,6 +25,7 @@ class Host extends Table{
'wildcard_status': {isRequired: false, type: 'string', min: 3, max: 500, default: 'Requesting'},
'wildcard_parent': {isRequired: false, type: 'string', min: 3, max: 500},
'wildcard_expires': {isRequired: false, type: 'number'},
'domain': {model: 'Domain', rel: 'one'},
}
static lookUpObj = {};
@@ -322,6 +317,7 @@ class Host extends Table{
return true;
}
}
Host.register(ModelPs(Host))
class Cached extends Table{
+9
View File
@@ -0,0 +1,9 @@
'use strict';
const Table = require('../utils/redis_model')
module.exports = Table;
require('./dns_provider');
require('./host');
require('./token');
require('./user');
+10 -11
View File
@@ -1,7 +1,6 @@
'use strict';
const Table = require('../utils/redis_model');
const {User} = require('./user');
const Table = require('.');
const UUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)};
@@ -11,8 +10,8 @@ class Token extends Table{
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
'created_on': {default: function(){return (new Date).getTime()}},
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
'token': {default: UUID, type: 'string', min: 36, max: 36},
'is_valid': {default: true, type: 'boolean'}
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
'is_valid': {default: true, type: 'boolean'},
}
constructor(...args){
@@ -28,13 +27,12 @@ class Token extends Table{
}
}
class AuthToken extends Token{
constructor(...args){
super(...args);
}
Token.register();
async getUser(){
return await User.get(this.created_by);
class AuthToken extends Token{
static _keyMap = {
...super._keyMap,
user: {model: 'User', rel: 'one', localKey: 'created_by'},
}
static async create(data){
@@ -42,8 +40,8 @@ class AuthToken extends Token{
return super.create(data)
}
}
AuthToken.register();
class InviteToken extends Token{
static _keyMap = {
@@ -66,5 +64,6 @@ class InviteToken extends Token{
}
}
}
InviteToken.register();
module.exports = {Token, InviteToken, AuthToken};
+1 -4
View File
@@ -58,12 +58,9 @@ class User extends Table{
throw error;
}
};
}
module.exports = {User};
User.register();
(async function(){
var defaultUser = 'proxyadmin2'
+2 -1
View File
@@ -264,7 +264,7 @@ MIT license
return {
...this.__parseData(data),
nestedTemplates: this.__parseNestedTemplates(index, data),
_parent: this.__jqParent ? $.scope[this.__jqParent][this.__jqParentIndex] : undefined,
_parent: this._parentData,
};
};
@@ -307,6 +307,7 @@ MIT license
result.__index = $this.attr('jq-repeat-index');
if($this.attr('jq-repeat-parent')){
result._parentData = $.scope[$this.attr('jq-repeat-parent')][$this.attr('jq-repeat-parent-index')]
result.__jqParent = $this.attr('jq-repeat-parent');
result.__jqParentIndex = $this.attr('jq-repeat-parent-index');
}
+12 -1
View File
@@ -1,7 +1,7 @@
'use strict';
const router = require('express').Router();
const {DnsProvider, Domain} = require('../models/dnsProvider');
const {DnsProvider, Domain} = require('../models').models;
const Model = DnsProvider;
@@ -49,6 +49,7 @@ router.get('/domain', async function(req, res, next){
}
});
router.get('/domain/byProvider/:item', async function(req, res, next){
try{
return res.json({
@@ -68,6 +69,16 @@ router.post('/domain/refresh/:item', async function(req, res, next){
}
})
router.get('/domain/:item', async function(req, res, next){
try{
return res.json({
results: [await Domain.get(req.params.item)]
});
}catch(error){
return next(error);
}
});
router.get('/:item', async function(req, res, next){
try{
+2 -2
View File
@@ -1,14 +1,14 @@
'use strict';
const router = require('express').Router();
const {Host} = require('../models/host');
const {Host, Domain} = require('../models').models;
const Model = Host;
router.get('/', async function(req, res, next){
try{
return res.json({
hosts: await Model[req.query.detail ? "listDetail" : "list"]()
results: await Model[req.query.detail ? "listDetail" : "list"](),
});
}catch(error){
return next(error);
+1 -1
View File
@@ -1,7 +1,7 @@
'use strict';
const router = require('express').Router();
const {User} = require('../models/user');
const {User} = require('../models').models;
router.get('/', async function(req, res, next){
try{
+3 -5
View File
@@ -22,10 +22,8 @@ function ModelPs(model){
}
return new Proxy(model, {
construct(target, args) {
let instance = ModelPs(new model(...args));
return instance;
construct(target, args, newTarget) {
return ModelPs(Reflect.construct(target, args, newTarget))
},
get(target, propKey, receiver) {
if(propKey == 'constructor') return target.constructor;
@@ -46,7 +44,7 @@ function ModelPs(model){
}
return res;
}catch(error){
console.log("grrrr")
console.log("grrrr", error)
}
}
} else {
+69 -9
View File
@@ -5,14 +5,37 @@ const objValidate = require('../utils/object_validate');
const conf = require('../conf');
const client = createClient({});
client.connect()
client.connect();
function redisPrefix(key){
return `${conf.redis.prefix}${key}`;
}
class QueryHelper{
hisroty = []
constructor(orgin){
this.orgin = orgin
this.hisroty.push(orgin.constructor.name);
}
static isNotCycle(modleName, queryHelper){
if(queryHelper instanceof this){
if(queryHelper.hisroty.includes(modleName)){
return true;
}
queryHelper.hisroty.push(modleName)
}
}
}
class Table{
static redisClient = client;
static models = {}
static register = function(Model){
Model = Model || this;
this.models[Model.name] = Model;
}
constructor(data){
for(let key in data){
@@ -20,9 +43,8 @@ class Table{
}
}
static async get(index){
static async get(index, queryHelper){
try{
if(typeof index === 'object'){
index = index[this._key];
}
@@ -43,13 +65,37 @@ class Table{
// back to native values.
result = objValidate.parseFromString(this._keyMap, result);
return new this(result);
let instance = new this(result);
await instance.buildRelations(queryHelper);
return instance;
}catch(error){
throw error;
}
}
async buildRelations(queryHelper){
for(let [key, options] of Object.entries(this.constructor._keyMap)){
if(options.model){
let remoteModel = this.constructor.models[options.model]
try{
if(QueryHelper.isNotCycle(remoteModel.name, queryHelper)) continue;
if(options.rel === 'one'){
// console.log('relone:', this[key], queryHelper, remoteModel, await remoteModel.get(this[key], queryHelper || new QueryHelper(this)))
this[key] = await remoteModel.get(this[key] || this[options.localKey || this.constructor._key] , queryHelper || new QueryHelper(this))
}
if(options.rel === 'many'){
this[key] = await remoteModel.listDetail({
[options.remoteKey]: this[options.localKey || this.constructor._key],
},queryHelper || new QueryHelper(this))
}
}catch{}
}
}
}
static async exists(index){
if(typeof index === 'object'){
index = index[this._key];
@@ -73,12 +119,21 @@ class Table{
}
}
static async listDetail(){
static async listDetail(options, queryHelper){
// Return a list of the entries as instances.
let out = [];
for(let entry of await this.list()){
out.push(await this.get(entry));
let instance = await this.get(entry, arguments[arguments.length - 1]);
if(!options) out.push(instance);
let matchCount = 0;
for(let option in options){
if(instance[option] === options[option] && ++matchCount === Object.keys(options).length){
out.push(instance);
break;
}
}
}
return out;
@@ -213,8 +268,9 @@ class Table{
toJSON(){
let result = {};
for (const [key, keyProps] of Object.entries(this.constructor._keyMap)) {
if(!keyProps.isPrivate) result[key] = this[key];
for (const [key, value] of Object.entries(this)) {
if(this.constructor._keyMap[key] && this.constructor._keyMap[key].isPrivate) continue;
result[key] = value;
}
return result
@@ -222,6 +278,10 @@ class Table{
// return JSON.stringify(result);
}
toString(){
return this[this.constructor._key];
}
}
+5 -12
View File
@@ -59,16 +59,10 @@
$.scope.DnsProvider.parseData = function(row){
row['created_on_text'] = moment(row['updated_on'], "x").fromNow();
row['domainsString'] = JSON.stringify(row.domains);
return row
};
(async function(){
})()
$(document).ready(async function(){
// Set the jq Templates
$.scope.providerField.put = function(){};
@@ -102,7 +96,6 @@
$.scope[Model].update(pk, data)
}catch{}
});
});
</script>
@@ -182,7 +175,7 @@
<li jq-repeat="DnsProvider" jq-repeat-index="id" style="display:none" class="list-group-item" onload="provider">
<div class="row">
<div class="col-6">
<h3>{{{ displayIconHtml }}} {{ dnsProvider }}</h3>
<h3><img height="32px" src="{{ displayIconHtml }}"/> {{ dnsProvider }}</h3>
<h4>{{ name }}</h4>
</div>
<div class="col-6 text-end">
@@ -200,9 +193,9 @@
{{domain}}
</b>
<script type="text/javascript">
app.api.get(`dns/domain/byProvider/{{id}}`, function(error, domains){
$.scope.Domain_{{id}}.push(...domains.results);
});
try{
$.scope.Domain_{{id}}.push(...{{{domainsString}}});
}catch{}
</script>
</div>
</li>
+9 -5
View File
@@ -62,10 +62,10 @@
}
function hostPopulate(){
app.host.list(function(error, res){
app.api.get('host?detail=1&provider=1', function(error, res){
if(error) return app.util.actionMessage(error, $.scope.hosts.$this, 'danger');
for(let host of res){
for(let host of res.results){
$.scope.hosts.push(hostParseRow(host));
}
@@ -150,9 +150,9 @@
$el.slideUp();
};
// app.subscribe(/^model:Host/, function(data, topic){
// console.log(topic, data);
// });
app.subscribe(/^model:Host/, function(data, topic){
console.log(topic, data);
});
app.subscribe(/^model:Host:create/, function(data, topic){
let [a,b, action, host] = topic.split(':');
@@ -391,6 +391,10 @@
<a target="_blank" href="{{ forcessl_text }}{{ host }}">
{{{ forcessl_text }}}{{ host }}
</a>
{{#domain.provider}}
<br />
<img width="24px" src="{{displayIconHtml}}" /> {{displayName}}
{{/domain.provider}}
</td>
<td>
{{{ targetssl_text }}}{{ ip }}:{{ targetPort }}
-44
View File
@@ -31,55 +31,11 @@
</head>
<body>
<!-- <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="#">Navbar</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Dropdown
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link disabled">Disabled</a>
</li>
</ul>
<form class="d-flex" role="search">
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
</div>
</nav> -->
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="#">Dynamic Proxy <%- title %></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<!-- <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button> -->
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
<ul class="navbar-nav top-nav">
<li class="nav-item">