@ntlab/sipd-tu-bridge-ui 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Toha <tohenk@yahoo.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # SIPD Penatausahaan Bridge Web Interface
2
+
3
+ An Express app as user interface of SIPD Penatausahaan Bridge Web Interface.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The MIT License (MIT)
3
+ *
4
+ * Copyright (c) 2026 Toha <tohenk@yahoo.com>
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
7
+ * this software and associated documentation files (the "Software"), to deal in
8
+ * the Software without restriction, including without limitation the rights to
9
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10
+ * of the Software, and to permit persons to whom the Software is furnished to do
11
+ * so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ const Controller = require('@ntlab/express-controller');
26
+ const Express = require('express').application;
27
+
28
+ class SecurityController extends Controller {
29
+ buildRoutes() {
30
+ this.addRoute('index', 'get', '/login', (req, res, next) => {
31
+ let redir;
32
+ if (req.params.r) {
33
+ redir = req.params.r;
34
+ } else if (req.query.r) {
35
+ redir = req.query.r;
36
+ }
37
+ res.app.slots.mainmenu.enabled = false;
38
+ res.render('security/login', {redirect: redir ? redir : req.getPath('/')});
39
+ });
40
+ this.addRoute('login', 'post', '/login', (req, res, next) => {
41
+ const result = {
42
+ success: false
43
+ }
44
+ if (req.user.authenticate(req.body.username, req.body.password)) {
45
+ req.user.login();
46
+ result.success = true;
47
+ result.url = req.body.continue ? req.body.continue : req.getPath('/');
48
+ } else {
49
+ result.error = this._('Invalid username and/or password');
50
+ }
51
+ res.json(result);
52
+ });
53
+ this.addRoute('logout', 'get', '/logout', (req, res, next) => {
54
+ if (req.user) {
55
+ req.user.logout();
56
+ }
57
+ res.redirect(req.getPath('/'));
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Create controller.
63
+ *
64
+ * @param {Express} app Express app
65
+ * @param {string} prefix Path prefix
66
+ * @returns {SecurityController}
67
+ */
68
+ static create(app, prefix = '/') {
69
+ const controller = new SecurityController({prefix: prefix, name: 'Security'});
70
+ app.use(prefix, controller.router);
71
+ return controller;
72
+ }
73
+ }
74
+
75
+ module.exports = SecurityController.create;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The MIT License (MIT)
3
+ *
4
+ * Copyright (c) 2026 Toha <tohenk@yahoo.com>
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
7
+ * this software and associated documentation files (the "Software"), to deal in
8
+ * the Software without restriction, including without limitation the rights to
9
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10
+ * of the Software, and to permit persons to whom the Software is furnished to do
11
+ * so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+ const Controller = require('@ntlab/express-controller');
28
+ const Express = require('express').application;
29
+
30
+ class UiController extends Controller {
31
+
32
+ buildRoutes() {
33
+ this.addRoute('index', 'get', '/', async (req, res, next) => {
34
+ /** @type {import('..').SipdApi} */
35
+ const api = req.app.api;
36
+ for (const bridge of api.bridges) {
37
+ bridge.stat = await bridge.getStats();
38
+ bridge.last = await bridge.getLast();
39
+ bridge.current = await bridge.getCurrent();
40
+ }
41
+ const socketOptions = {reconnection: true};
42
+ if (req.app.get('root') !== '/') {
43
+ socketOptions.path = req.getPath('/socket.io/');
44
+ }
45
+ res.render('ui/index', {
46
+ socket: {
47
+ url: `${req.getUri({noproto: true})}/ui`,
48
+ options: socketOptions,
49
+ }
50
+ });
51
+ });
52
+ this.addRoute('updates', 'get', '/updates', async (req, res, next) => {
53
+ const result = {updates: {}};
54
+ /** @type {import('..').SipdApi} */
55
+ const api = req.app.api;
56
+ for (const bridge of api.bridges) {
57
+ bridge.stat = await bridge.getStats();
58
+ bridge.last = await bridge.getLast();
59
+ bridge.current = await bridge.getCurrent();
60
+ result.updates[bridge.name] = {
61
+ ...bridge.stat,
62
+ last: bridge.last,
63
+ current: bridge.current,
64
+ }
65
+ }
66
+ res.json(result);
67
+ });
68
+ this.addRoute('activity', 'get', '/activity', async (req, res, next) => {
69
+ const result = {};
70
+ /** @type {import('..').SipdApi} */
71
+ const api = req.app.api;
72
+ const logs = await api.getActivity();
73
+ if (logs) {
74
+ result.time = Date.now();
75
+ result.logs = logs;
76
+ }
77
+ res.json(result);
78
+ });
79
+ this.addRoute('queue', 'get', '/queue', async (req, res, next) => {
80
+ /** @type {import('..').SipdApi} */
81
+ const api = req.app.api;
82
+ const result = await api.getQueues(req.params.page || req.query.page, req.params.size || req.query.size);
83
+ result.pages = req.app.locals.pager(result.count, result.size, result.page);
84
+ res.json(result);
85
+ });
86
+ this.addRoute('log', 'get', '/log/:bridge', async (req, res, next) => {
87
+ const result = {};
88
+ if (req.params.bridge) {
89
+ /** @type {import('..').SipdApi} */
90
+ const api = req.app.api;
91
+ const bridge = api.bridges.find(b => b.name === req.params.bridge);
92
+ if (bridge) {
93
+ const logs = await bridge.getLogs();
94
+ if (logs) {
95
+ result.time = Date.now();
96
+ result.logs = logs;
97
+ }
98
+ }
99
+ }
100
+ res.json(result);
101
+ });
102
+ this.addRoute('about', 'get', '/about', (req, res, next) => {
103
+ let about;
104
+ if (req.app.about) {
105
+ about = req.app.about;
106
+ } else {
107
+ const packageInfo = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json')));
108
+ about = {
109
+ title: packageInfo.description,
110
+ version: packageInfo.version,
111
+ author: packageInfo.author.name ? `${packageInfo.author.name} <${packageInfo.author.email}>` :
112
+ packageInfo.author,
113
+ license: packageInfo.license
114
+ }
115
+ }
116
+ res.json(about);
117
+ });
118
+ this.addRoute('task', 'post', '/task/:op', (req, res, next) => {
119
+ const result = {
120
+ success: false
121
+ }
122
+ res.json(result);
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Create controller.
128
+ *
129
+ * @param {Express} app Express app
130
+ * @param {string} prefix Path prefix
131
+ * @returns {UiController}
132
+ */
133
+ static create(app, prefix = '/') {
134
+ const controller = new UiController({prefix, name: 'Ui'});
135
+ app.use(prefix, controller.router);
136
+ return controller;
137
+ }
138
+ }
139
+
140
+ module.exports = UiController.create;
package/helper/app.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The MIT License (MIT)
3
+ *
4
+ * Copyright (c) 2026 Toha <tohenk@yahoo.com>
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
7
+ * this software and associated documentation files (the "Software"), to deal in
8
+ * the Software without restriction, including without limitation the rights to
9
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10
+ * of the Software, and to permit persons to whom the Software is furnished to do
11
+ * so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ const Helper = require('@ntlab/express-middleware/lib/helper');
26
+ const HelperFunctions = require('@ntlab/express-middleware/lib/fn');
27
+ const Controller = require('@ntlab/express-controller');
28
+ const Translator = require('@ntlab/express-controller/translator');
29
+ const Stringify = require('@ntlab/ntlib/stringify');
30
+ const { ScriptManager } = require('@ntlab/ntjs');
31
+ const { minify_sync } = require('terser');
32
+
33
+ /**
34
+ * Express app middleware.
35
+ */
36
+ class AppFunctions extends HelperFunctions {
37
+
38
+ initialize() {
39
+ this.exportFn(this.app.locals, () => this.ViewFunctions());
40
+ this.exportFn(this.res.req, () => this.RequestFunctions());
41
+ this.exportFn(this.res, () => this.ResponseFunctions());
42
+ }
43
+
44
+ ViewFunctions() {
45
+ return {
46
+ _: Translator._,
47
+ s: (o, l = 0) => Stringify.from(o, l),
48
+ route: (name, parameters) => this.genRoute(name, parameters),
49
+ path: path => this.genPath(path),
50
+ }
51
+ }
52
+
53
+ RequestFunctions() {
54
+ return {
55
+ getUri: (parameters = null) => this.getUri(parameters),
56
+ getPath: path => this.genPath(path),
57
+ getRoute: (name, parameters) => this.genRoute(name, parameters),
58
+ }
59
+ }
60
+
61
+ ResponseFunctions() {
62
+ return {
63
+ onrender: res => ScriptManager.require('JQuery').setOption('xhr', res.req.xhr ? true : false),
64
+ onscript: script => this.app.get('env') === 'development' ? script : minify_sync(script, {compress: true, mangle: true}).code,
65
+ }
66
+ }
67
+
68
+ getUri(parameters = null) {
69
+ let path, noproto = false;
70
+ if (typeof parameters === 'string') {
71
+ path = parameters;
72
+ parameters = {};
73
+ }
74
+ if (typeof parameters === 'object') {
75
+ if (parameters.path) {
76
+ path = parameters.path;
77
+ }
78
+ if (parameters.noproto) {
79
+ noproto = true;
80
+ }
81
+ }
82
+ const [host, port] = this.res.req.headers.host.split(':');
83
+ let uri = `${noproto ? '' : this.res.req.protocol + ':'}//${this.res.req.hostname}`;
84
+ if (port && ((this.res.req.protocol === 'http' && port != 80) || (this.res.req.protocol === 'https' && port != 443))) {
85
+ uri += `:${port}`;
86
+ }
87
+ if (path) {
88
+ uri += this.genPath(path);
89
+ }
90
+ return uri;
91
+ }
92
+
93
+ getController(name) {
94
+ return Controller.get(name);
95
+ }
96
+
97
+ genRoute(name, parameters) {
98
+ const controller = this.getController(name);
99
+ if (!controller) {
100
+ throw new Error(`Unable to find controller ${name}!`);
101
+ }
102
+ const p = {...parameters};
103
+ const route = p.name;
104
+ if (!route) {
105
+ throw new Error('Route name must be specified in parameters!');
106
+ }
107
+ delete p.name;
108
+ return controller.genRoute(route, p);
109
+ }
110
+
111
+ genPath(path) {
112
+ if (Array.isArray(path)) {
113
+ path = path.map(p => this.genPath(p));
114
+ } else {
115
+ if (typeof path === 'string' && !path.match(/http(s)?:\/\//) && path.substr(0, 1) === '/') {
116
+ let rootPath = this.app.get('root');
117
+ if (rootPath.substr(-1) === '/') {
118
+ rootPath = rootPath.substr(0, rootPath.length - 1);
119
+ }
120
+ if (rootPath) {
121
+ path = rootPath + path;
122
+ }
123
+ }
124
+ }
125
+ return path;
126
+ }
127
+ }
128
+
129
+ const helper = new Helper(AppFunctions);
130
+
131
+ module.exports = options => helper.handle(options);
package/index.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The MIT License (MIT)
3
+ *
4
+ * Copyright (c) 2026 Toha <tohenk@yahoo.com>
5
+ *
6
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
7
+ * this software and associated documentation files (the "Software"), to deal in
8
+ * the Software without restriction, including without limitation the rights to
9
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
10
+ * of the Software, and to permit persons to whom the Software is furnished to do
11
+ * so, subject to the following conditions:
12
+ *
13
+ * The above copyright notice and this permission notice shall be included in all
14
+ * copies or substantial portions of the Software.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ * SOFTWARE.
23
+ */
24
+
25
+ /* --- BEGIN API --- */
26
+
27
+ /**
28
+ * SIPD Penatausahaan Bridge main application.
29
+ *
30
+ * @typedef {Object} SipdApi
31
+ * @property {string} title Application title
32
+ * @property {SipdAboutInfo} about Application information
33
+ * @property {string} proto Protocol version
34
+ * @property {string} mode Bridge mode
35
+ * @property {object} config Configuration
36
+ * @property {SipdBridge[]} bridges Bridges
37
+ * @property {AuthenticateFunction} authenticate Perform usename and password authentication
38
+ * @property {GetQueuesFunction} getQueues Get queues
39
+ * @property {StringPromiseFunction} getActivity Get activity logs
40
+ */
41
+
42
+ /**
43
+ * SIPD Penatausahaan Bridge queue consumer.
44
+ *
45
+ * @typedef {Object} SipdBridge
46
+ * @property {string} name Name
47
+ * @property {number} year Year
48
+ * @property {ObjectPromiseFunction} getStats Get bridge stats
49
+ * @property {StringPromiseFunction} getLogs Get bridge logs
50
+ * @property {ObjectPromiseFunction} getLast Get last queue
51
+ * @property {ObjectPromiseFunction} getCurrent Get current processing queue
52
+ */
53
+
54
+ /**
55
+ * Application information.
56
+ *
57
+ * @typedef {Object} SipdAboutInfo
58
+ * @property {string} title Title
59
+ * @property {string} version Version
60
+ * @property {string} author Author name and email address
61
+ * @property {string} license License, e.g. MIT License
62
+ */
63
+
64
+ /**
65
+ * Perform usename and password authentication.
66
+ *
67
+ * @callback AuthenticateFunction
68
+ * @param {string} username Username
69
+ * @param {string} password Password
70
+ * @returns {boolean}
71
+ */
72
+
73
+ /**
74
+ * Get queues.
75
+ *
76
+ * @callback GetQueuesFunction
77
+ * @param {number} page Page number
78
+ * @param {number} size Page size
79
+ * @returns {Promise<object[]>}
80
+ */
81
+
82
+ /**
83
+ * A function which returns object Promise.
84
+ *
85
+ * @callback ObjectPromiseFunction
86
+ * @returns {Promise<object>}
87
+ */
88
+
89
+ /**
90
+ * A function which returns string Promise.
91
+ *
92
+ * @callback StringPromiseFunction
93
+ * @returns {Promise<string>}
94
+ */
95
+
96
+ /* --- END API --- */
97
+
98
+ const createError = require('http-errors');
99
+ const express = require('express');
100
+ const path = require('path');
101
+ const logger = require('morgan');
102
+ const session = require('express-session');
103
+ const FileStore = require('session-file-store')(session);
104
+ const { Helper, Security, Factory } = require('@ntlab/express-middleware');
105
+ const { ScriptManager, ScriptAsset } = require('@ntlab/ntjs');
106
+ const { Assets, CDN } = require('@ntlab/ntjs-assets');
107
+
108
+ // register script repository
109
+ require('@ntlab/ntjs-repo')();
110
+
111
+ /**
112
+ * Express application.
113
+ *
114
+ * @author Toha <tohenk@yahoo.com>
115
+ */
116
+ class ExpressApp {
117
+
118
+ app = express()
119
+ /** @type {SipdApi} */
120
+ api = null
121
+
122
+ /**
123
+ * Constructor.
124
+ *
125
+ * @param {SipdApi} api App api
126
+ */
127
+ constructor(api) {
128
+ this.api = api;
129
+ this.initialize();
130
+ }
131
+
132
+ initialize() {
133
+ const options = this.api.config || {};
134
+ const rootPath = options.rootPath || '/';
135
+
136
+ // setup application
137
+ this.app.api = this.app.locals.api = this.api;
138
+ this.app.factory = Factory.SemanticUI;
139
+ for (const prop of ['title', 'about', 'authenticate']) {
140
+ this.app[prop] = this.api[prop];
141
+ }
142
+
143
+ // view engine setup
144
+ this.app.set('views', path.join(__dirname, 'views'));
145
+ this.app.set('view engine', 'ejs');
146
+ this.app.set('query parser', 'extended');
147
+
148
+ // environment
149
+ this.app.set('env', process.env.NODE_ENV || 'development');
150
+ this.app.set('root', rootPath);
151
+
152
+ this.app.use(logger('dev'));
153
+ this.app.use(express.json());
154
+ this.app.use(express.urlencoded({extended: true}));
155
+ this.app.use(rootPath, express.static(path.join(__dirname, 'public')));
156
+ this.app.use(rootPath, express.static(Assets));
157
+
158
+ // session
159
+ const sessiondir = options.sessiondir || path.join(__dirname, 'sessions');
160
+ const secret = options.sessionsecret || 'sipd-tu-bridge';
161
+ this.app.use(session({
162
+ name: options['session-name'] || 'sipd-tu-bridge',
163
+ store: new FileStore({path: sessiondir}),
164
+ secret,
165
+ resave: false,
166
+ saveUninitialized: false,
167
+ cookie: {
168
+ maxAge: 3600000
169
+ }
170
+ })
171
+ );
172
+
173
+ // security
174
+ const securityOptions = {};
175
+ if (rootPath !== '/') {
176
+ securityOptions.loginroute = options.getPath('/login');
177
+ securityOptions.logoutroute = options.getPath('/logout');
178
+ }
179
+ this.app.use(Security.core(securityOptions));
180
+
181
+ // app helpers
182
+ this.app.use(Helper.core());
183
+ this.app.use(Helper.menu());
184
+ this.app.use(Helper.pager());
185
+ this.app.use(require('./helper/app')());
186
+
187
+ // controllers
188
+ const Controller = require('@ntlab/express-controller');
189
+ Controller.scan(path.join(__dirname, 'controller'), controller => {
190
+ controller(this.app, rootPath);
191
+ });
192
+
193
+ // catch 404 and forward to error handler
194
+ this.app.use((req, res, next) => {
195
+ next(createError(404));
196
+ });
197
+
198
+ // error handler
199
+ this.app.use((err, req, res, next) => {
200
+ // set locals, only providing error in development
201
+ res.locals.message = err.message;
202
+ res.locals.error = req.app.get('env') === 'development' ? err : {};
203
+ res.locals.viewdir = req.app.get('views');
204
+
205
+ // render the error page
206
+ res.status(err.status || 500);
207
+ res.render('error/error');
208
+ });
209
+
210
+ // relative from layout
211
+ this.app.slots = {
212
+ mainmenu: {
213
+ view: '../slot/mainmenu'
214
+ },
215
+ addons: {
216
+ view: '../slot/addons'
217
+ }
218
+ }
219
+
220
+ ScriptManager.addDefault('SemanticUI');
221
+ ScriptManager.addDefaultAsset(ScriptAsset.STYLESHEET, 'app.css');
222
+ if (options.useCdn) {
223
+ ScriptManager.parseCdn(CDN);
224
+ }
225
+ ScriptManager.require('JQuery/FormPost')
226
+ .setOption('redir-delay', 1000);
227
+ ScriptManager.translator = require('@ntlab/express-controller/translator')._;
228
+ ScriptManager.config = options;
229
+ }
230
+ }
231
+
232
+ /** @type {ExpressApp} */
233
+ let app = null;
234
+
235
+ function run(api) {
236
+ if (app === null) {
237
+ app = new ExpressApp(api);
238
+ } else {
239
+ console.error('User interface already created!');
240
+ }
241
+ return app.app;
242
+ }
243
+
244
+ module.exports = run;
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@ntlab/sipd-tu-bridge-ui",
3
+ "version": "1.0.0",
4
+ "description": "SIPD Penatausahaan Bridge Web Interface",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/tohenk/node-sipd-tu-bridge-ui.git"
12
+ },
13
+ "keywords": [
14
+ "sipd",
15
+ "penatausahaan",
16
+ "kemendagri",
17
+ "spp",
18
+ "spp-ls",
19
+ "lpj",
20
+ "npd",
21
+ "tbp",
22
+ "otomasi",
23
+ "selenium",
24
+ "socket.io",
25
+ "web-interface"
26
+ ],
27
+ "author": "Toha <tohenk@yahoo.com>",
28
+ "license": "MIT",
29
+ "bugs": {
30
+ "url": "https://github.com/tohenk/node-sipd-tu-bridge-ui/issues"
31
+ },
32
+ "homepage": "https://github.com/tohenk/node-sipd-tu-bridge-ui#readme",
33
+ "dependencies": {
34
+ "@ntlab/express-controller": "^1.2.0",
35
+ "@ntlab/express-middleware": "^2.4.0",
36
+ "@ntlab/ntjs": "^3.0.0",
37
+ "@ntlab/ntjs-assets": "^2.139.0",
38
+ "@ntlab/ntjs-repo": "^3.0.1",
39
+ "@ntlab/ntlib": "^2.9.0",
40
+ "ejs": "^3.1.10",
41
+ "express": "^5.2.1",
42
+ "express-session": "^1.19.0",
43
+ "http-errors": "^2.0.1",
44
+ "moment": "^2.30.1",
45
+ "morgan": "^1.10.1",
46
+ "session-file-store": "^1.5.0",
47
+ "terser": "^5.46.0"
48
+ }
49
+ }
@@ -0,0 +1,16 @@
1
+ body.with-menu {
2
+ padding-top: 5em;
3
+ }
4
+
5
+ body:not([class]) main, #login-container {
6
+ height: 100%;
7
+ }
8
+
9
+ #login-container > div {
10
+ margin-top: auto !important;
11
+ margin-bottom: auto !important;
12
+ }
13
+
14
+ img.logo {
15
+ margin-right: 1em !important;
16
+ }
Binary file
Binary file
@@ -0,0 +1,6 @@
1
+ <h2><%= error instanceof Error ? error.constructor.name : _('An error occured!') %></h2>
2
+ <%_ if (error) { -%>
3
+ <pre><%= error.stack %></pre>
4
+ <%_ } else { -%>
5
+ <pre><%= _(message) %></pre>
6
+ <%_ } -%>
@@ -0,0 +1,41 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
7
+ <title><%- sitetitle %></title>
8
+ <%_ stylesheets().forEach(css => { -%>
9
+ <link rel="stylesheet" href="<%- path(css) %>" />
10
+ <%_ }) -%>
11
+ <%_ if (mainmenu = slot('mainmenu')) { -%>
12
+ <%_ mainmenu = include(mainmenu) -%>
13
+ <%_ } -%>
14
+ </head>
15
+ <body<% if (mainmenu) { %> class="with-menu"<% } %>>
16
+ <%_ if (mainmenu) { -%>
17
+ <section class="ui fixed inverted menu">
18
+ <%- mainmenu -%>
19
+ </section>
20
+ <%_ } -%>
21
+ <main class="ui container">
22
+ <%_ if (title.length) { -%>
23
+ <h1 class="ui dividing header"><%= title %></h1>
24
+ <%_ } -%>
25
+ <%- content -%>
26
+ </main>
27
+ <%_ if ((addons = slot('addons')) && (addons = include(addons))) { -%>
28
+ <%- addons -%>
29
+ <%_ } -%>
30
+ <%_ javascripts().forEach(js => { -%>
31
+ <script type="text/javascript" src="<%- path(js) %>"></script>
32
+ <%_ }) -%>
33
+ <%_ if ((scriptContent = scripts()).length) { -%>
34
+ <script type="text/javascript">
35
+ // <![CDATA[
36
+ <%- scriptContent %>
37
+ // ]]>
38
+ </script>
39
+ <%_ } -%>
40
+ </body>
41
+ </html>
@@ -0,0 +1,9 @@
1
+ <%- jsloader({css: path(stylesheets()), js: path(javascripts())}) -%>
2
+ <%- content -%>
3
+ <%_ if ((scriptContent = scripts()).length) { -%>
4
+ <script type="text/javascript">
5
+ // <![CDATA[
6
+ <%- scriptContent %>
7
+ // ]]>
8
+ </script>
9
+ <%_ } -%>
@@ -0,0 +1,62 @@
1
+ <div id="login-container" class="ui center aligned stackable grid">
2
+ <div class="six wide column">
3
+ <h2 class="ui image header">
4
+ <img src="<%= path('/images/logo.png') %>" class="image">
5
+ <div class="content"><%= _('Log-in to %APP%', {APP: apptitle}) %></div>
6
+ </h2>
7
+ <form class="ui large form login" action="<%= route('Security', {name: 'login'}) %>">
8
+ <input type="hidden" name="continue" value="<%= redirect %>">
9
+ <div class="ui left aligned segment">
10
+ <div class="ui red message hidden">
11
+ <i class="red exclamation triangle icon"></i><span class="errmsg"></span>
12
+ </div>
13
+ <div class="field">
14
+ <div class="ui big left icon input">
15
+ <i class="user icon"></i>
16
+ <input type="text" name="username" class="required" placeholder="<%= _('Username') %>" autocomplete="off">
17
+ </div>
18
+ </div>
19
+ <div class="field">
20
+ <div class="ui big left icon input">
21
+ <i class="lock icon"></i>
22
+ <input type="password" name="password" class="required" placeholder="<%= _('Password') %>">
23
+ </div>
24
+ </div>
25
+ <button class="ui fluid large submit button" type="submit"><%= _('Login') %></button>
26
+ </div>
27
+ </form>
28
+ </div>
29
+ </div>
30
+ <%_ script.create('JQuery')
31
+ .useDependencies(['JQuery/Util', 'JQuery/FormPost'])
32
+ .addMiddle(`
33
+ $.login = function(form) {
34
+ const fp = $.formpost(form, {progress: false, xhr: true});
35
+ const err = fp.errhelper;
36
+ err.errorContainer = form.find('.message');
37
+ err.errorFormat = err.ERROR_INPLACE;
38
+ err.inplace = function(el, error) {
39
+ el.find('.errmsg').html(error);
40
+ }
41
+ form
42
+ .on('formrequest', function(e) {
43
+ form.find('input[type=password]').val('');
44
+ })
45
+ .on('formsaved', function(e, json) {
46
+ if (json.url) {
47
+ if (typeof $.loginSuccess === 'function') {
48
+ $.loginSuccess(json.url);
49
+ } else {
50
+ window.location.href = json.url;
51
+ }
52
+ } else {
53
+ window.location.reload();
54
+ }
55
+ })
56
+ ;
57
+ form.find('input[name=username]').focus();
58
+ }
59
+ `)
60
+ .addLast(`
61
+ $.login($('form.login'));
62
+ `) -%>
@@ -0,0 +1,3 @@
1
+ <%- include('errhandler') -%>
2
+ <%- include('errhandler401') -%>
3
+ <%- include('errhandler500') -%>
@@ -0,0 +1,27 @@
1
+ <%_ script.create('JQuery')
2
+ .useDependencies(['JQuery/Util'])
3
+ .addMiddle(`
4
+ $.define('errhandler', {
5
+ handlers: {},
6
+ register(code, handler) {
7
+ const self = this;
8
+ if (!self.handlers[code]) {
9
+ self.handlers[code] = [];
10
+ }
11
+ self.handlers[code].push(handler);
12
+ },
13
+ handle(code) {
14
+ const self = this;
15
+ if (self.handlers[code]) {
16
+ for (let i = 0; i < self.handlers[code].length; i++) {
17
+ if (typeof self.handlers[code][i] === 'function') {
18
+ self.handlers[code][i].call();
19
+ }
20
+ }
21
+ }
22
+ }
23
+ });
24
+ $(document).ajaxError(function(event, xhr, s, e) {
25
+ $.errhandler.handle(xhr.status);
26
+ });
27
+ `) -%>
@@ -0,0 +1,19 @@
1
+ <%_ script.create('JQuery')
2
+ .useDependencies(['JQuery/Util', 'SemanticUI/Dialog'])
3
+ .addMiddle(`
4
+ $.errhandler.register(401, function() {
5
+ const dlgId = 'err401';
6
+ if ($.ntdlg.isVisible(dlgId)) {
7
+ return;
8
+ }
9
+ $.ntdlg.dialog(dlgId, '${_('Information')}', '${_('Your session has been expired, please try to login again!')}', $.ntdlg.ICON_ALERT, {
10
+ okay: {
11
+ type: 'green approve',
12
+ caption: '<i class="check icon"></i>${_('Ok')}',
13
+ handler() {
14
+ window.location.reload();
15
+ }
16
+ }
17
+ });
18
+ });
19
+ `) -%>
@@ -0,0 +1,11 @@
1
+ <%_ script.create('JQuery')
2
+ .useDependencies(['JQuery/Util', 'SemanticUI/Dialog/Message'])
3
+ .addMiddle(`
4
+ $.errhandler.register(500, function() {
5
+ const dlgId = 'err500';
6
+ if ($.ntdlg.isVisible(dlgId)) {
7
+ return;
8
+ }
9
+ $.ntdlg.message(dlgId, '${_('Error')}', '${_('Oops! An Error Occurred, please try again later.<br/>Don\\\'t hesitate to contact us if the problem persist.')}', $.ntdlg.ICON_ERROR);
10
+ });
11
+ `) -%>
@@ -0,0 +1,69 @@
1
+ <%_ menus = {
2
+ branding: {
3
+ type: 'brand',
4
+ title: apptitle,
5
+ logo: path('/images/logo.png'),
6
+ url: path('/')
7
+ },
8
+ tasks: {
9
+ title: _('Tasks'),
10
+ class: 'right floated',
11
+ items: {
12
+ about: {
13
+ title: _('About')
14
+ }
15
+ }
16
+ }
17
+ } -%>
18
+ <%_ if (user.authenticated) {
19
+ Object.assign(menus.tasks.items, {
20
+ divider1: {
21
+ type: 'divider'
22
+ },
23
+ logout: {
24
+ title: _('Logout'),
25
+ url: route('Security', {name: 'logout'})
26
+ }
27
+ });
28
+ } -%>
29
+ <%- menu(menus, {mainmenu: true, indentation: 2}) %>
30
+ <%_ script.create('JQuery')
31
+ .useDependencies(['JQuery/Util', 'SemanticUI/Notification', 'SemanticUI/Dialog/Message'])
32
+ .addMiddle(`
33
+ $.tasks = {
34
+ about() {
35
+ const self = this;
36
+ const f = function() {
37
+ const msg = $.util.template(
38
+ '<p>%TITLE% version %VERSION%<br/>\\n' +
39
+ '&copy; %AUTHOR%<br/>\\n' +
40
+ 'Licensed under %LICENSE%</p>', {
41
+ TITLE: self.aboutInfo.title,
42
+ VERSION: self.aboutInfo.version,
43
+ AUTHOR: $('<div>').text(self.aboutInfo.author).html(),
44
+ LICENSE: self.aboutInfo.license
45
+ }
46
+ );
47
+ $.ntdlg.message('task-about', '${_('About')}', msg, $.ntdlg.ICON_INFO);
48
+ }
49
+ if (!self.aboutInfo) {
50
+ $.get('${route('Ui', {name: 'about'})}')
51
+ .done(function(json) {
52
+ self.aboutInfo = json;
53
+ f();
54
+ });
55
+ } else {
56
+ f();
57
+ }
58
+ },
59
+ init() {
60
+ const self = this;
61
+ $('.menu-about').on('click', function(e) {
62
+ e.preventDefault();
63
+ self.about();
64
+ });
65
+ }
66
+ }
67
+ `).addLast(`
68
+ $.tasks.init();
69
+ `) -%>
@@ -0,0 +1,34 @@
1
+ <div class="ui form">
2
+ <textarea class="activity" rows="30" readonly></textarea>
3
+ </div>
4
+ <%_ script.create('JQuery')
5
+ .addMiddle(`
6
+ $.activity = {
7
+ el: $('textarea.activity'),
8
+ url: '${route('Ui', {name: 'activity'})}',
9
+ load() {
10
+ const self = this;
11
+ $.get(self.url)
12
+ .done(function(json) {
13
+ if (json.logs) {
14
+ self.el.text(json.logs);
15
+ self.time = json.time;
16
+ }
17
+ });
18
+ },
19
+ add(data) {
20
+ const self = this;
21
+ if (data.time >= self.time) {
22
+ const message = data.message + '\\r\\n';
23
+ if (self.el.text().substr(-message.length) !== message) {
24
+ self.el.append(message);
25
+ self.el.scrollTop(self.el[0].scrollHeight - self.el.height());
26
+ self.time = data.time;
27
+ }
28
+ }
29
+ }
30
+ }
31
+ `)
32
+ .addLast(`
33
+ $.activity.load();
34
+ `) -%>
@@ -0,0 +1,42 @@
1
+ <%_ bridges = api.bridges -%>
2
+ <%_ names = bridges.map(b => b.name) -%>
3
+ <div class="ui stackable grid">
4
+ <div class="row">
5
+ <div class="four wide column">
6
+ <div class="ui fluid vertical pointing menu">
7
+ <%_ bridges.forEach(bridge => { -%>
8
+ <a href="#" class="item" data-bridge="<%= bridge.name %>"><i class="laptop code icon"></i> <%= bridge.name.toUpperCase() %></a>
9
+ <%_ }) -%>
10
+ </div>
11
+ </div>
12
+ <div class="twelve wide column">
13
+ <%_ bridges.forEach(bridge => { -%>
14
+ <div class="ui form bridge <%= bridge.name %>" style="display: none;">
15
+ <%_ Object.keys(bridge.stat).forEach(stat => { -%>
16
+ <div class="fields">
17
+ <div class="six wide field"><label><%= _(bridge.stat[stat].label) %></label></div>
18
+ <div class="ten wide field" data-key="<%= stat %>"><input type="text" value="<%- bridge.stat[stat].value %>" readonly></div>
19
+ </div>
20
+ <%_ }) -%>
21
+ <div class="field">
22
+ <label><%= _('Logs') %></label>
23
+ <textarea class="log <%= bridge.name %>" rows="20" readonly></textarea>
24
+ </div>
25
+ </div>
26
+ <%_ }) -%>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ <%_ items = JSON.stringify(names, null, 4) -%>
31
+ <%_ script.create('JQuery')
32
+ .add(`
33
+ $.bridge.init(${items});
34
+ $('a[data-bridge]').on('click', function(e) {
35
+ e.preventDefault();
36
+ const a = $(this);
37
+ a.siblings('a').removeClass('active');
38
+ a.addClass('active');
39
+ $(\`.ui.form.bridge\`).hide();
40
+ $(\`.ui.form.bridge.\${a.data('bridge')}\`).show();
41
+ }).filter(':first').click();
42
+ `) -%>
@@ -0,0 +1,58 @@
1
+ <%_ script.create('JQuery')
2
+ .addMiddle(`
3
+ $.bridge = {
4
+ bridges: {},
5
+ getLog(name) {
6
+ const self = this;
7
+ $.get('${route('Ui', {name: 'log', bridge: 'BRIDGE'})}'.replace(/BRIDGE/, name))
8
+ .done(function(json) {
9
+ if (json.logs) {
10
+ const log = $(self.bridges[name].log);
11
+ log.text(json.logs);
12
+ self.bridges[name].time = json.time;
13
+ }
14
+ });
15
+ },
16
+ addLog(name, data) {
17
+ const self = this;
18
+ if (data.time >= self.bridges[name].time) {
19
+ const log = $(self.bridges[name].log);
20
+ const message = data.message + '\\r\\n';
21
+ if (log.text().substr(-message.length) !== message) {
22
+ log.append(message);
23
+ log.scrollTop(log[0].scrollHeight - log.height());
24
+ self.bridges[name].time = data.time;
25
+ }
26
+ }
27
+ },
28
+ update() {
29
+ const self = this;
30
+ $.get('${route('Ui', {name: 'updates'})}')
31
+ .done(function(json) {
32
+ if (json.updates) {
33
+ for (const bridge of Object.keys(json.updates)) {
34
+ for (const [k, v] of Object.entries(json.updates[bridge])) {
35
+ $(\`[data-bridge="\${bridge}"] [data-key="\${k}"]\`).each(function() {
36
+ const el = $(this);
37
+ if (el.is('input')) {
38
+ el.val(v);
39
+ } else {
40
+ el.html(v ? v : '&ndash;');
41
+ }
42
+ });
43
+ }
44
+ }
45
+ }
46
+ });
47
+ },
48
+ init(bridges) {
49
+ const self = this;
50
+ for (const bridge of bridges) {
51
+ self.bridges[bridge] = {
52
+ log: $(\`.log.\${bridge}\`)
53
+ }
54
+ self.getLog(bridge);
55
+ }
56
+ }
57
+ }
58
+ `) -%>
@@ -0,0 +1,64 @@
1
+ <div class="ui top attached stackable tabular menu">
2
+ <a class="item" data-tab="status"><%= _('Status') %></a>
3
+ <a class="item" data-tab="activity"><%= _('Activity') %></a>
4
+ <a class="item" data-tab="queue"><%= _('Queue') %></a>
5
+ <%_ if (api.bridges.length) { -%>
6
+ <a class="item" data-tab="bridge"><%= _('Bridge') %> <span class="ui tiny label"><%= api.bridges.length %></span></a>
7
+ <%_ } -%>
8
+ </div>
9
+ <div class="ui bottom attached tab segment" data-tab="status">
10
+ <%- include('status') -%>
11
+ </div>
12
+ <div class="ui bottom attached tab segment" data-tab="activity">
13
+ <%- include('activity') -%>
14
+ </div>
15
+ <div class="ui bottom attached tab segment" data-tab="queue">
16
+ <%- include('queue') -%>
17
+ </div>
18
+ <%- include('bridgehandler') -%>
19
+ <%_ if (api.bridges.length) { -%>
20
+ <div class="ui bottom attached tab segment" data-tab="bridge">
21
+ <%- include('bridge') -%>
22
+ </div>
23
+ <%_ } -%>
24
+ <%_ script.create('JQuery')
25
+ .useDependencies(['SocketIO'])
26
+ .addMiddle(`
27
+ $.uiCon = {
28
+ connected: false,
29
+ data: ${s(socket, 1)},
30
+ init() {
31
+ const self = this;
32
+ self.socket = io.connect(self.data.url, self.data.options);
33
+ self.socket
34
+ .on('connect', function() {
35
+ self.connected = true;
36
+ console.log('Socket connected');
37
+ })
38
+ .on('disconnect', function() {
39
+ self.connected = false;
40
+ console.log('Socket disconnected');
41
+ })
42
+ .on('activity', function(data) {
43
+ if (Array.isArray(data)) {
44
+ for (const msg of data) {
45
+ $.activity.add(msg);
46
+ }
47
+ }
48
+ })
49
+ .on('queue', function() {
50
+ $.queue.reload();
51
+ $.bridge.update();
52
+ });
53
+ }
54
+ }
55
+ `)
56
+ .addLast(`
57
+ $('.menu .item').tab({
58
+ autoTabActivation: window.location.hash ? window.location.hash.substr(1) : true,
59
+ onLoad(path, params, history) {
60
+ window.location.hash = path;
61
+ }
62
+ });
63
+ $.uiCon.init();
64
+ `) -%>
@@ -0,0 +1,17 @@
1
+ <div class="ui fluid card">
2
+ <div class="content">
3
+ <div class="item header"><i class="ui info circle icon"></i><%- _('General Information') %></div>
4
+ </div>
5
+ <div class="extra content">
6
+ <div class="ui form">
7
+ <div class="fields">
8
+ <div class="six wide field"><%= _('Protocol:') %></div>
9
+ <div class="ten wide field"><%= api.proto %></div>
10
+ </div>
11
+ <div class="fields">
12
+ <div class="six wide field"><%= _('Mode:') %></div>
13
+ <div class="ten wide field"><%= api.mode %></div>
14
+ </div>
15
+ </div>
16
+ </div>
17
+ </div>
@@ -0,0 +1,66 @@
1
+ <h3 class="ui header x-title"></h3>
2
+ <table class="ui selectable celled table">
3
+ <thead>
4
+ <tr>
5
+ <th><%= _('#') %></th>
6
+ <th><%= _('Id') %></th>
7
+ <th><%= _('Type') %></th>
8
+ <th><%= _('Name') %></th>
9
+ <th><%= _('Status') %></th>
10
+ <th><%= _('Result') %></th>
11
+ <th><%= _('Time') %></th>
12
+ </tr>
13
+ </thead>
14
+ </table>
15
+ <%_ script.create('JQuery')
16
+ .useDependencies('SemanticUI/Loader')
17
+ .addMiddle(`
18
+ $.queue = $.loader($('div[data-tab="queue"] table'), {
19
+ url: '${route('Ui', {name: 'queue', page: 'PAGE'})}',
20
+ formatRow(item) {
21
+ return this.toRow(item);
22
+ },
23
+ loaded() {
24
+ const self = this;
25
+ if (self.loading) {
26
+ self.loading = false;
27
+ }
28
+ }
29
+ });
30
+ $.queue.toRow = function(data) {
31
+ return $(
32
+ \`<tr><td>\${data.nr}</td>
33
+ <td>\${this.toData(data.id)}</td>
34
+ <td>\${this.toData(data.type)}</td>
35
+ <td>\${this.toData(data.name)}</td>
36
+ <td>\${this.toStatus(data.status)}</td>
37
+ <td>\${this.toData(data.result)}</td>
38
+ <td>\${this.toData(data.time)}</td>
39
+ </tr>\`);
40
+ }
41
+ $.queue.toData = function(data) {
42
+ return data !== undefined ? data.toString() : '';
43
+ }
44
+ $.queue.toStatus = function(data) {
45
+ const icon = {
46
+ new: 'pause circle outline',
47
+ processing: 'spinner',
48
+ done: 'green check',
49
+ error: 'red times',
50
+ timeout: 'clock outline',
51
+ skipped: 'exclamation',
52
+ }[data];
53
+ return icon ? \`<div data-tooltip="\${data}" data-position="right center"><i class="\${icon} icon"></i></div>\` :
54
+ this.toData(data);
55
+ }
56
+ $.queue.reload = function() {
57
+ const self = this;
58
+ if (!self.loading) {
59
+ self.loading = true;
60
+ self.load();
61
+ }
62
+ }
63
+ `)
64
+ .addLast(`
65
+ $.queue.load();
66
+ `) -%>
@@ -0,0 +1,84 @@
1
+ <div class="ui centered stackable grid">
2
+ <div class="row"></div>
3
+ <div class="row">
4
+ <div class="twelve wide column">
5
+ <%- include('info') -%>
6
+ </div>
7
+ </div>
8
+ <%_ if (api.bridges.length) { -%>
9
+ <div class="row">
10
+ <div class="twelve wide column">
11
+ <div class="ui horizontal divider">
12
+ <%- _('Bridges') %>
13
+ </div>
14
+ </div>
15
+ </div>
16
+ <%_ api.bridges.forEach(bridge => { -%>
17
+ <%_ stats = Object.keys(bridge.stat) -%>
18
+ <div class="twelve wide column">
19
+ <div class="ui fluid card">
20
+ <div class="content">
21
+ <div class="ui right floated tiny label"><%= bridge.year %></div>
22
+ <div class="item header"><i class="ui laptop code icon"></i><%= bridge.name.toUpperCase() %></div>
23
+ </div>
24
+ <div class="extra content" data-bridge="<%= bridge.name %>">
25
+ <div class="ui divided items">
26
+ <!-- stat -->
27
+ <div class="center aligned item">
28
+ <div class="ui fluid tiny statistic">
29
+ <div class="value">
30
+ <div class="ui breadcrumb">
31
+ <%_ stats.forEach((stat, i) => { -%>
32
+ <div class="section" data-key="<%= stat %>"><%- bridge.stat[stat].value ? bridge.stat[stat].value : '&ndash;' %></div>
33
+ <%_ if (i < stats.length - 1) { -%>
34
+ <div class="divider"> / </div>
35
+ <%_ } -%>
36
+ <%_ }) -%>
37
+ </div>
38
+ </div>
39
+ <div class="label">
40
+ <div class="ui breadcrumb">
41
+ <%_ stats.forEach((stat, i) => { -%>
42
+ <div class="section"><%- _(bridge.stat[stat].label) %></div>
43
+ <%_ if (i < stats.length - 1) { -%>
44
+ <div class="divider"> / </div>
45
+ <%_ } -%>
46
+ <%_ }) -%>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ <!-- stat end -->
52
+ <!-- processing -->
53
+ <div class="center aligned item">
54
+ <div class="ui fluid tiny statistic">
55
+ <div class="value" data-key="current"><%- bridge.current ?? '&ndash;' %></div>
56
+ <div class="label"><%- _('Queue in progress') %></div>
57
+ </div>
58
+ </div>
59
+ <!-- processing end -->
60
+ <!-- last -->
61
+ <div class="center aligned item">
62
+ <div class="ui fluid tiny statistic">
63
+ <div class="value" data-key="last"><%- bridge.last ?? '&ndash;' %></div>
64
+ <div class="label"><%- _('Last queue') %></div>
65
+ </div>
66
+ </div>
67
+ <!-- last end -->
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ <%_ }) -%>
73
+ <%_ } else { -%>
74
+ <div class="ui placeholder segment" style="min-height: 75vh;">
75
+ <div class="ui icon header">
76
+ <i class="laptop code icon"></i>
77
+ <div class="ui big hidden divider"></div>
78
+ <%= _('Currently, no connected bridge available.') %><br/>
79
+ <%= _('Once a connection established, it will appear here.') %>
80
+ </div>
81
+ </div>
82
+ <%_ } -%>
83
+ <div class="row"></div>
84
+ </div>