pinokiod 3.8.26 → 3.8.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/kernel/caddy.js +235 -86
- package/kernel/index.js +3 -0
- package/kernel/peer.js +65 -0
- package/package.json +1 -1
- package/server/index.js +12 -0
- package/server/views/proxy.ejs +9 -1
package/kernel/caddy.js
CHANGED
|
@@ -4,34 +4,16 @@ class Caddy {
|
|
|
4
4
|
constructor(kernel) {
|
|
5
5
|
this.kernel = kernel
|
|
6
6
|
}
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
let
|
|
10
|
-
|
|
11
|
-
let response = await axios.post('http://127.0.0.1:2019/load', payload, {
|
|
12
|
-
headers: { 'Content-Type': 'application/json' }
|
|
13
|
-
}).catch((e) => {
|
|
14
|
-
console.log("EEEEEEEEEEEEE", e)
|
|
15
|
-
})
|
|
16
|
-
console.log("Caddy Response", { response })
|
|
17
|
-
}
|
|
18
|
-
async procs (payload) {
|
|
19
|
-
console.log("####### procs", payload)
|
|
20
|
-
let list = this.kernel.processes.list
|
|
21
|
-
if (!list) {
|
|
22
|
-
list = await this.kernel.processes.refresh()
|
|
23
|
-
}
|
|
24
|
-
for(let item of list) {
|
|
25
|
-
let url = `${item.port}.localhost`
|
|
26
|
-
this.mapping[item.ip] = url
|
|
7
|
+
peers_home (payload) {
|
|
8
|
+
let peers = this.kernel.peer.get()
|
|
9
|
+
for(let ip of peers) {
|
|
10
|
+
let account = this.kernel.peer.account(ip)
|
|
27
11
|
payload.apps.http.servers.main.routes.push({
|
|
28
|
-
"match": [{
|
|
29
|
-
"host": [url],
|
|
30
|
-
}],
|
|
12
|
+
"match": [{ "host": [`pinokio.${account}.localhost`] }],
|
|
31
13
|
"handle": [
|
|
32
14
|
{
|
|
33
15
|
"handler": "reverse_proxy",
|
|
34
|
-
"upstreams": [{ "dial":
|
|
16
|
+
"upstreams": [{ "dial": `${ip}:42000` }]
|
|
35
17
|
},
|
|
36
18
|
{
|
|
37
19
|
"handler": "headers",
|
|
@@ -49,6 +31,136 @@ class Caddy {
|
|
|
49
31
|
}
|
|
50
32
|
return payload
|
|
51
33
|
}
|
|
34
|
+
home () {
|
|
35
|
+
return {
|
|
36
|
+
"apps": {
|
|
37
|
+
"http": {
|
|
38
|
+
"servers": {
|
|
39
|
+
"main": {
|
|
40
|
+
"listen": [":443"],
|
|
41
|
+
"routes": [
|
|
42
|
+
{
|
|
43
|
+
"match": [{ "method": ["OPTIONS"] }],
|
|
44
|
+
"handle": [
|
|
45
|
+
{
|
|
46
|
+
"handler": "headers",
|
|
47
|
+
"response": {
|
|
48
|
+
"set": {
|
|
49
|
+
"Access-Control-Allow-Origin": ["*"],
|
|
50
|
+
"Access-Control-Allow-Methods": ["GET, POST, OPTIONS, PUT, DELETE"],
|
|
51
|
+
"Access-Control-Allow-Headers": ["*"],
|
|
52
|
+
"Vary": ["Origin"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"handler": "static_response",
|
|
58
|
+
"status_code": 204
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"match": [{ "host": [`pinokio.localhost`] }],
|
|
64
|
+
"handle": [
|
|
65
|
+
{
|
|
66
|
+
"handler": "reverse_proxy",
|
|
67
|
+
"upstreams": [{ "dial": "127.0.0.1:42000" }]
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"handler": "headers",
|
|
71
|
+
"response": {
|
|
72
|
+
"set": {
|
|
73
|
+
"Access-Control-Allow-Origin": ["*"],
|
|
74
|
+
"Access-Control-Allow-Methods": ["GET, POST, OPTIONS, PUT, DELETE"],
|
|
75
|
+
"Access-Control-Allow-Headers": ["*"],
|
|
76
|
+
"Vary": ["Origin"]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
peers_locals(payload) {
|
|
90
|
+
let peers = this.kernel.peer.get()
|
|
91
|
+
for(let ip of peers) {
|
|
92
|
+
let account = this.kernel.peer.account(ip)
|
|
93
|
+
let local = this.kernel.peer.local(ip)
|
|
94
|
+
// construct Caddy update payload
|
|
95
|
+
console.log({ local })
|
|
96
|
+
for(let script_path in local) {
|
|
97
|
+
let local_variables = local[script_path]
|
|
98
|
+
console.log({ local_variables })
|
|
99
|
+
for(let key in local_variables) {
|
|
100
|
+
let val = local_variables[key]
|
|
101
|
+
if (val.startsWith("http")) {
|
|
102
|
+
val = val.replace(/https?:\/\//, '')
|
|
103
|
+
|
|
104
|
+
// replace 127,0.0.1 or localhost with the 192.168 address
|
|
105
|
+
val = val.replace(/localhost|127\.0\.0\.1/g, ip)
|
|
106
|
+
let api_path = path.resolve(this.kernel.peer.homedir(ip), "api")
|
|
107
|
+
let rel_path = path.relative(api_path, script_path)
|
|
108
|
+
let name = rel_path.split(path.sep)[0]
|
|
109
|
+
console.log({ api_path, rel_path, name, val })
|
|
110
|
+
if (key === "url") {
|
|
111
|
+
let url = `${name}.${account}.localhost`.toLowerCase()
|
|
112
|
+
this.mapping[val] = url
|
|
113
|
+
payload.apps.http.servers.main.routes.push({
|
|
114
|
+
"match": [{
|
|
115
|
+
"host": [url]
|
|
116
|
+
}],
|
|
117
|
+
"handle": [
|
|
118
|
+
{
|
|
119
|
+
"handler": "reverse_proxy",
|
|
120
|
+
"upstreams": [{ "dial": val }]
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"handler": "headers",
|
|
124
|
+
"response": {
|
|
125
|
+
"set": {
|
|
126
|
+
"Access-Control-Allow-Origin": ["*"],
|
|
127
|
+
"Access-Control-Allow-Methods": ["GET, POST, OPTIONS, PUT, DELETE"],
|
|
128
|
+
"Access-Control-Allow-Headers": ["*"],
|
|
129
|
+
"Vary": ["Origin"]
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
payload.apps.http.servers.main.routes.push({
|
|
137
|
+
"match": [{
|
|
138
|
+
"host": [`${key}.${name}${account}.localhost`.toLowerCase()],
|
|
139
|
+
}],
|
|
140
|
+
"handle": [
|
|
141
|
+
{
|
|
142
|
+
"handler": "reverse_proxy",
|
|
143
|
+
"upstreams": [{ "dial": val }]
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"handler": "headers",
|
|
147
|
+
"response": {
|
|
148
|
+
"set": {
|
|
149
|
+
"Access-Control-Allow-Origin": ["*"],
|
|
150
|
+
"Access-Control-Allow-Methods": ["GET, POST, OPTIONS, PUT, DELETE"],
|
|
151
|
+
"Access-Control-Allow-Headers": ["*"],
|
|
152
|
+
"Vary": ["Origin"]
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return payload
|
|
163
|
+
}
|
|
52
164
|
locals (payload) {
|
|
53
165
|
// construct Caddy update payload
|
|
54
166
|
let local = this.kernel.memory.local;
|
|
@@ -70,13 +182,8 @@ class Caddy {
|
|
|
70
182
|
payload.apps.http.servers.main.routes.push({
|
|
71
183
|
"match": [{
|
|
72
184
|
"host": [url]
|
|
73
|
-
//"path": [`/local/${key}/${uri}/*`]
|
|
74
185
|
}],
|
|
75
186
|
"handle": [
|
|
76
|
-
//{
|
|
77
|
-
// "handler": "rewrite",
|
|
78
|
-
// "uri": "/"
|
|
79
|
-
//},
|
|
80
187
|
{
|
|
81
188
|
"handler": "reverse_proxy",
|
|
82
189
|
"upstreams": [{ "dial": val }]
|
|
@@ -98,13 +205,8 @@ class Caddy {
|
|
|
98
205
|
payload.apps.http.servers.main.routes.push({
|
|
99
206
|
"match": [{
|
|
100
207
|
"host": [`${key}.${name}.localhost`.toLowerCase()],
|
|
101
|
-
//"path": [`/local/${key}/${uri}/*`]
|
|
102
208
|
}],
|
|
103
209
|
"handle": [
|
|
104
|
-
//{
|
|
105
|
-
// "handler": "rewrite",
|
|
106
|
-
// "uri": "/"
|
|
107
|
-
//},
|
|
108
210
|
{
|
|
109
211
|
"handler": "reverse_proxy",
|
|
110
212
|
"upstreams": [{ "dial": val }]
|
|
@@ -127,66 +229,113 @@ class Caddy {
|
|
|
127
229
|
}
|
|
128
230
|
return payload
|
|
129
231
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
]
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
"
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"handler": "reverse_proxy",
|
|
164
|
-
"upstreams": [{ "dial": "127.0.0.1:42000" }]
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
"handler": "headers",
|
|
168
|
-
"response": {
|
|
169
|
-
"set": {
|
|
170
|
-
"Access-Control-Allow-Origin": ["*"],
|
|
171
|
-
"Access-Control-Allow-Methods": ["GET, POST, OPTIONS, PUT, DELETE"],
|
|
172
|
-
"Access-Control-Allow-Headers": ["*"],
|
|
173
|
-
"Vary": ["Origin"]
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
]
|
|
178
|
-
},
|
|
179
|
-
]
|
|
232
|
+
async peers_procs (payload) {
|
|
233
|
+
console.log("####### procs", payload)
|
|
234
|
+
let list = this.kernel.peer.get()
|
|
235
|
+
for(let ip of list) {
|
|
236
|
+
let account = this.kernel.peer.account(ip)
|
|
237
|
+
|
|
238
|
+
// caddy_mapping := { IP => HTTPS_DOMAIN }
|
|
239
|
+
let caddy_mapping = this.kernel.peer.caddy(ip)
|
|
240
|
+
for(let app_ip in caddy_mapping) {
|
|
241
|
+
let https_domain = caddy_mapping[app_ip]
|
|
242
|
+
let remote_app_ip = app_ip.replace(/localhost|127\.0\.0\.1/g, ip)
|
|
243
|
+
// https_domain <port>.localhost
|
|
244
|
+
let url = https_domain.replace(/\.localhost$/, '') + `${account}.localhost`
|
|
245
|
+
this.mapping[remote_app_ip] = url
|
|
246
|
+
payload.apps.http.servers.main.routes.push({
|
|
247
|
+
"match": [{
|
|
248
|
+
"host": [url],
|
|
249
|
+
}],
|
|
250
|
+
"handle": [
|
|
251
|
+
{
|
|
252
|
+
"handler": "reverse_proxy",
|
|
253
|
+
"upstreams": [{ "dial": remote_app_ip }]
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"handler": "headers",
|
|
257
|
+
"response": {
|
|
258
|
+
"set": {
|
|
259
|
+
"Access-Control-Allow-Origin": ["*"],
|
|
260
|
+
"Access-Control-Allow-Methods": ["GET, POST, OPTIONS, PUT, DELETE"],
|
|
261
|
+
"Access-Control-Allow-Headers": ["*"],
|
|
262
|
+
"Vary": ["Origin"]
|
|
263
|
+
}
|
|
264
|
+
}
|
|
180
265
|
}
|
|
181
|
-
|
|
182
|
-
}
|
|
266
|
+
]
|
|
267
|
+
})
|
|
183
268
|
}
|
|
184
269
|
}
|
|
270
|
+
return payload
|
|
271
|
+
}
|
|
272
|
+
async procs (payload) {
|
|
273
|
+
console.log("####### procs", payload)
|
|
274
|
+
let list = this.kernel.processes.list
|
|
275
|
+
if (!list) {
|
|
276
|
+
list = await this.kernel.processes.refresh()
|
|
277
|
+
}
|
|
278
|
+
for(let item of list) {
|
|
279
|
+
let url = `${item.port}.localhost`
|
|
280
|
+
this.mapping[item.ip] = url
|
|
281
|
+
payload.apps.http.servers.main.routes.push({
|
|
282
|
+
"match": [{
|
|
283
|
+
"host": [url],
|
|
284
|
+
}],
|
|
285
|
+
"handle": [
|
|
286
|
+
{
|
|
287
|
+
"handler": "reverse_proxy",
|
|
288
|
+
"upstreams": [{ "dial": item.ip }]
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"handler": "headers",
|
|
292
|
+
"response": {
|
|
293
|
+
"set": {
|
|
294
|
+
"Access-Control-Allow-Origin": ["*"],
|
|
295
|
+
"Access-Control-Allow-Methods": ["GET, POST, OPTIONS, PUT, DELETE"],
|
|
296
|
+
"Access-Control-Allow-Headers": ["*"],
|
|
297
|
+
"Vary": ["Origin"]
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
]
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
return payload
|
|
305
|
+
}
|
|
306
|
+
peers(payload) {
|
|
307
|
+
let peers = this.kernel.peer.get()
|
|
308
|
+
for(let peer of peers) {
|
|
309
|
+
let account = peer.username
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async update() {
|
|
313
|
+
this.mapping = {}
|
|
314
|
+
let payload = await this.payload()
|
|
315
|
+
console.log("Make request to caddy", JSON.stringify(payload, null, 2))
|
|
316
|
+
let response = await axios.post('http://127.0.0.1:2019/load', payload, {
|
|
317
|
+
headers: { 'Content-Type': 'application/json' }
|
|
318
|
+
}).catch((e) => {
|
|
319
|
+
console.log("EEEEEEEEEEEEE", e)
|
|
320
|
+
})
|
|
321
|
+
console.log("Caddy Response", { response })
|
|
185
322
|
}
|
|
186
323
|
async payload () {
|
|
324
|
+
|
|
325
|
+
// localhost home
|
|
187
326
|
let payload = this.home()
|
|
327
|
+
// peers home
|
|
328
|
+
let peers = this.kernel.peer.get()
|
|
329
|
+
payload = this.peers_home(payload)
|
|
330
|
+
|
|
331
|
+
// localhost local variables
|
|
188
332
|
payload = this.locals(payload)
|
|
333
|
+
// peers local variables
|
|
334
|
+
payload = this.peers_locals(payload)
|
|
335
|
+
|
|
189
336
|
payload = await this.procs(payload)
|
|
337
|
+
payload = this.peers_procs(payload)
|
|
338
|
+
|
|
190
339
|
return payload
|
|
191
340
|
}
|
|
192
341
|
}
|
package/kernel/index.js
CHANGED
|
@@ -32,6 +32,7 @@ const Store = require('./store')
|
|
|
32
32
|
const Proto = require('./prototype')
|
|
33
33
|
const Caddy = require("./caddy")
|
|
34
34
|
const Procs = require('./procs')
|
|
35
|
+
const Peer = require('./peer')
|
|
35
36
|
const VARS = {
|
|
36
37
|
pip: {
|
|
37
38
|
install: {
|
|
@@ -86,6 +87,8 @@ class Kernel {
|
|
|
86
87
|
this.kv = new KV(this)
|
|
87
88
|
this.cloudflare = new Cloudflare()
|
|
88
89
|
this.shellpath = shellPath.sync()
|
|
90
|
+
|
|
91
|
+
this.peer = new Peer()
|
|
89
92
|
}
|
|
90
93
|
/*
|
|
91
94
|
kernel.env() => return the system environment
|
package/kernel/peer.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const dgram = require('dgram');
|
|
2
|
+
const axios = require('axios');
|
|
3
|
+
const os = require('os')
|
|
4
|
+
class PeerDiscovery {
|
|
5
|
+
constructor(port = 41234, message = 'ping', interval = 1000) {
|
|
6
|
+
this.port = port;
|
|
7
|
+
this.message = Buffer.from(message);
|
|
8
|
+
this.interval = interval;
|
|
9
|
+
this.socket = dgram.createSocket('udp4');
|
|
10
|
+
this.peers = new Set();
|
|
11
|
+
this.current_account = os.userInfo()
|
|
12
|
+
// this.start();
|
|
13
|
+
}
|
|
14
|
+
account(ip) {
|
|
15
|
+
return this.data[ip].account
|
|
16
|
+
}
|
|
17
|
+
home(ip) {
|
|
18
|
+
return this.data[ip].homedir
|
|
19
|
+
}
|
|
20
|
+
caddy(ip) {
|
|
21
|
+
return this.data[ip].caddy
|
|
22
|
+
}
|
|
23
|
+
local(ip) {
|
|
24
|
+
return this.data[ip].memory.local
|
|
25
|
+
}
|
|
26
|
+
global(ip) {
|
|
27
|
+
return this.data[ip].memory.global
|
|
28
|
+
}
|
|
29
|
+
start() {
|
|
30
|
+
// Listen for incoming pings
|
|
31
|
+
this.data = {}
|
|
32
|
+
this.socket.on('message', (msg, rinfo) => {
|
|
33
|
+
const ip = rinfo.address;
|
|
34
|
+
if (msg.toString() === this.message.toString() && this._isLocalLAN(ip)) {
|
|
35
|
+
if (!this.peers.has(ip)) {
|
|
36
|
+
this.peers.add(ip);
|
|
37
|
+
axios.get(`http://${ip}:42000/pinokio/mem`).then((res) => {
|
|
38
|
+
this.data[ip] = res.data
|
|
39
|
+
})
|
|
40
|
+
console.log(`Discovered peer: ${ip}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Enable broadcast
|
|
46
|
+
this.socket.bind(this.port, () => {
|
|
47
|
+
this.socket.setBroadcast(true);
|
|
48
|
+
|
|
49
|
+
// Send broadcast pings every interval
|
|
50
|
+
setInterval(() => {
|
|
51
|
+
this.socket.send(this.message, 0, this.message.length, this.port, '255.255.255.255');
|
|
52
|
+
}, this.interval);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get() {
|
|
57
|
+
return Array.from(this.peers);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_isLocalLAN(ip) {
|
|
61
|
+
return ip.startsWith('192.168.');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = PeerDiscovery;
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -2668,6 +2668,8 @@ class Server {
|
|
|
2668
2668
|
})
|
|
2669
2669
|
await this.kernel.caddy.update()
|
|
2670
2670
|
|
|
2671
|
+
let peers = this.kernel.peer.get()
|
|
2672
|
+
|
|
2671
2673
|
let live_proxies = this.kernel.api.proxies["/proxy"]
|
|
2672
2674
|
if (!live_proxies) live_proxies = []
|
|
2673
2675
|
let proxies = []
|
|
@@ -2745,6 +2747,7 @@ class Server {
|
|
|
2745
2747
|
}
|
|
2746
2748
|
}
|
|
2747
2749
|
res.render("proxy", {
|
|
2750
|
+
peers,
|
|
2748
2751
|
list,
|
|
2749
2752
|
agent: this.agent,
|
|
2750
2753
|
theme: this.theme,
|
|
@@ -3274,6 +3277,14 @@ class Server {
|
|
|
3274
3277
|
*/
|
|
3275
3278
|
|
|
3276
3279
|
}))
|
|
3280
|
+
this.app.get("/pinokio/mem", ex((req, res) => {
|
|
3281
|
+
res.json({
|
|
3282
|
+
homedir: this.kernel.homedir,
|
|
3283
|
+
account: this.kernel.peer.current_account,
|
|
3284
|
+
caddy: this.kernel.caddy.mapping, // mapping: { IP => https_domain }
|
|
3285
|
+
memory: this.kernel.memory
|
|
3286
|
+
})
|
|
3287
|
+
}))
|
|
3277
3288
|
this.app.get("/pinokio/memory", ex((req, res) => {
|
|
3278
3289
|
let filepath = req.query.filepath
|
|
3279
3290
|
let mem = this.getMemory(filepath)
|
|
@@ -3893,6 +3904,7 @@ class Server {
|
|
|
3893
3904
|
server: this.listening
|
|
3894
3905
|
});
|
|
3895
3906
|
})
|
|
3907
|
+
this.kernel.peer.start()
|
|
3896
3908
|
console.timeEnd("SERVER START")
|
|
3897
3909
|
|
|
3898
3910
|
|
package/server/views/proxy.ejs
CHANGED
|
@@ -161,7 +161,7 @@ body.dark .browser-options-row {
|
|
|
161
161
|
}
|
|
162
162
|
.container {
|
|
163
163
|
display: grid;
|
|
164
|
-
grid-template-columns: repeat(
|
|
164
|
+
grid-template-columns: repeat(4, 1fr);
|
|
165
165
|
}
|
|
166
166
|
.dark .container-row {
|
|
167
167
|
background: rgba(255, 255, 255, 0.04);
|
|
@@ -210,6 +210,14 @@ body.dark .browser-options-row {
|
|
|
210
210
|
<div class='header-label'><i class='fa-solid fa-wifi'></i> Local Sharing</div>
|
|
211
211
|
<div class='desc'>Serve Pinokio, installed apps, and even external apps to any device on the same network.</div>
|
|
212
212
|
<div class='container'>
|
|
213
|
+
<div class='container-row'>
|
|
214
|
+
<div class='header-label-sub'><i class="fa-solid fa-microchip"></i> Peers</div>
|
|
215
|
+
<% peers.forEach((item) => { %>
|
|
216
|
+
<a target="_blank" href="http://<%=item%>" class='line align-top'>
|
|
217
|
+
<h3 class='table-grid'><%=item%></h3>
|
|
218
|
+
</a>
|
|
219
|
+
<% }) %>
|
|
220
|
+
</div>
|
|
213
221
|
<div class='container-row'>
|
|
214
222
|
<div class='header-label-sub'><i class="fa-solid fa-microchip"></i> Apps</div>
|
|
215
223
|
<div class='line align-top'>
|