Settings has a "Sort rows by" choice: the technician list order (the default, as before), technician name, Total open, Closed today or any status column. Counts sort busiest first with ties by name, and the sorted column's header gets an arrow. The browser does the sorting; the server only validates and saves tableSort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
6.0 KiB
JavaScript
140 lines
6.0 KiB
JavaScript
// sdp-request-watcher: help desk dashboard for ManageEngine SDP Cloud
|
|
// Skylar Grant, with Claude (Anthropic). Page design by Isaac Godin.
|
|
|
|
require('dotenv').config({ quiet: true });
|
|
|
|
const path = require('path');
|
|
const express = require('express');
|
|
const { createZohoAuth } = require('./auth');
|
|
const { createSessions } = require('./session');
|
|
const { createAuthRoutes, createApiRoutes } = require('./routes');
|
|
const { createWeatherService } = require('./weather');
|
|
const { createUserSettings } = require('./settings');
|
|
const { createNameCache } = require('./names');
|
|
const places = require('./places');
|
|
|
|
const { CLIENT_ID, CLIENT_SECRET, SESSION_SECRET, TECHNICIAN_NAMES, STATUS_NAMES, SITE_NAMES, GROUP_NAMES, OWM_API_KEY, OWM_ZIP_CODE } = process.env;
|
|
const PORT = process.env.PORT || 3000; // default 3000
|
|
// the address people open; zoho sends them back to PUBLIC_URL/auth/callback after sign-in
|
|
const PUBLIC_URL = (process.env.PUBLIC_URL || `http://localhost:${PORT}`).replace(/\/+$/, '');
|
|
|
|
const toList = value => (value ? value.split(',') : []).map(v => v.trim()).filter(Boolean);
|
|
|
|
// any Zoho account can approve our client, so sign-in alone doesn't mean someone is staff
|
|
const allowedDomains = toList(process.env.ALLOWED_EMAIL_DOMAINS).map(d => d.replace(/^@/, '').toLowerCase());
|
|
|
|
const problems = [
|
|
!CLIENT_ID || !CLIENT_SECRET ? 'CLIENT_ID and CLIENT_SECRET are required' : null,
|
|
!SESSION_SECRET || SESSION_SECRET.length < 32 ? 'SESSION_SECRET must be at least 32 characters (try: openssl rand -hex 32)' : null,
|
|
!allowedDomains.length ? 'ALLOWED_EMAIL_DOMAINS is required, e.g. mainecc.edu' : null
|
|
].filter(Boolean);
|
|
if (problems.length) {
|
|
problems.forEach(p => console.error(p));
|
|
process.exit(1);
|
|
}
|
|
|
|
const technicians = toList(TECHNICIAN_NAMES);
|
|
const statuses = toList(STATUS_NAMES);
|
|
// statuses that count as done; everything else shows up in the workload table
|
|
const closedStatuses = statuses.length ? statuses : ['Closed', 'Resolved', 'Canceled'];
|
|
|
|
// the status rules every user shares; these aren't in the settings panel (the feed's statuses are, see feedStatuses)
|
|
const settings = {
|
|
closedStatuses,
|
|
// statuses the unassigned counter ignores, to match the ManageEngine view
|
|
unassignedExcludedStatuses: process.env.UNASSIGNED_EXCLUDED_STATUSES !== undefined
|
|
? toList(process.env.UNASSIGNED_EXCLUDED_STATUSES)
|
|
: closedStatuses
|
|
};
|
|
|
|
const timing = {
|
|
windowMinutes: 10, // how far back each closed check looks, in minutes
|
|
checkMinutes: 1, // how often the page checks for newly closed tickets, in minutes
|
|
sessionDays: Number(process.env.SESSION_DAYS) || 7 // how long a sign-in lasts before Zoho is asked again
|
|
};
|
|
|
|
const zoho = createZohoAuth({
|
|
clientId: CLIENT_ID,
|
|
clientSecret: CLIENT_SECRET,
|
|
redirectUri: `${PUBLIC_URL}/auth/callback`
|
|
});
|
|
|
|
const sessions = createSessions({
|
|
secret: SESSION_SECRET,
|
|
// the proxy terminates https, so go by the public address rather than the request
|
|
secure: PUBLIC_URL.startsWith('https://'),
|
|
// exact domain match on the email Zoho reports; no email means no entry
|
|
isAllowed: user => {
|
|
const email = (user?.email || '').toLowerCase();
|
|
return allowedDomains.some(domain => email.endsWith('@' + domain));
|
|
},
|
|
sessionDays: timing.sessionDays,
|
|
zoho
|
|
});
|
|
|
|
// weather widget is optional; it stays hidden without an API key
|
|
const weatherService = createWeatherService({ apiKey: OWM_API_KEY });
|
|
|
|
// the .env zip is the default location; make sure it's on the list people pick from
|
|
if (OWM_ZIP_CODE && !places.some(p => p.zip === OWM_ZIP_CODE)) {
|
|
places.unshift({ zip: OWM_ZIP_CODE, label: `Default (${OWM_ZIP_CODE})` });
|
|
}
|
|
|
|
// what each user starts with; the settings panel overrides these per user
|
|
const defaults = {
|
|
sites: toList(SITE_NAMES), // empty means every site
|
|
groups: toList(GROUP_NAMES), // empty means every group
|
|
technicians, // empty means everyone, plus an Unassigned row
|
|
workloadMinutes: Number(process.env.WORKLOAD_MINUTES) || 5, // how often the page recounts the status table
|
|
feedHours: 12, // how long a closed ticket stays in the feed
|
|
weatherZip: OWM_ZIP_CODE || null,
|
|
hiddenStatuses: [], // status columns left out of the table
|
|
tableSort: 'list', // see TABLE_SORTS in settings.js
|
|
// statuses that put a ticket in the closed feed and ding; some people want Resolved,
|
|
// which comes days before SDP closes a ticket on its own
|
|
feedStatuses: closedStatuses,
|
|
hiddenPanels: [] // see PANELS in settings.js
|
|
};
|
|
|
|
// settings and name suggestions are the only things the server keeps; mount a volume here in docker
|
|
const dataDir = path.resolve(process.env.DATA_DIR || path.join(__dirname, '..', 'data'));
|
|
const userSettings = createUserSettings({ dataDir, defaults, places });
|
|
const nameCache = createNameCache({
|
|
dataDir,
|
|
seed: () => {
|
|
const saved = userSettings.allNames();
|
|
return {
|
|
sites: [...defaults.sites, ...saved.sites],
|
|
groups: [...defaults.groups, ...saved.groups],
|
|
technicians: [...defaults.technicians, ...saved.technicians]
|
|
};
|
|
}
|
|
});
|
|
|
|
const app = express();
|
|
app.disable('x-powered-by');
|
|
// behind nginx proxy manager: believe X-Forwarded-For from private addresses (the docker network)
|
|
// so logs show the real client ip
|
|
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal');
|
|
|
|
app.use((req, res, next) => {
|
|
res.set({
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'X-Frame-Options': 'DENY',
|
|
'Referrer-Policy': 'same-origin'
|
|
});
|
|
next();
|
|
});
|
|
|
|
// for the docker healthcheck; says nothing about sign-in or data
|
|
app.get('/healthz', (req, res) => res.send('ok'));
|
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
app.use('/auth', createAuthRoutes({ zoho, sessions }));
|
|
app.use('/api', createApiRoutes({ sessions, weatherService, settings, timing, userSettings, nameCache, places }));
|
|
|
|
//starts listening on port specified
|
|
app.listen(PORT, () => {
|
|
console.log(`Listening on port ${PORT}, public address ${PUBLIC_URL}, data in ${dataDir}`);
|
|
});
|