mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
feat: hostname-aware window title (#82)
Set the browser tab title to codeman:${hostname} instead of the bare
"Codeman" literal. Useful for users running multiple Codeman instances
across hosts (laptop, dev box, NAS) — the OS hostname disambiguates
which tab points at which backend.
Implementation:
- src/cli.ts: new --title-hostname <hostname> flag overrides the
detected hostname (handy for cosmetic naming or when os.hostname()
returns something noisy).
- src/web/server.ts: WebServer now accepts an optional titleHostname
constructor arg (defaults to os.hostname()), composes
windowTitle = codeman:${titleHostname}, and serves / and
/index.html by templating that title into the cached index.html
template (with HTML escaping of the title text).
- src/web/public/notification-manager.js: title-flash logic now uses
this.originalTitle instead of the hardcoded "Codeman" literal, so
the tab flash respects the per-host title.
- scripts/browser-comparison.mjs + test/file-link-click.test.ts:
expectations updated from === "Codeman" to a startsWith("codeman:")
predicate so they pass regardless of host.
The new index.html templating is intentionally narrow — it only
substitutes the <title> tag and continues to serve everything else
from the static template. No JS-side title injection, so it works
without JavaScript and shows the correct title from the very first
paint.
Note: test/file-link-click.test.ts shows ~49 prettier-reformat lines
that are not part of the feature — they are pre-existing prettier
debt that the pre-commit hook required me to clear. The single
behavioral change is the browserAvailable line.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
eade261763
commit
b7e94e7068
@@ -19,6 +19,10 @@ const PORTS = {
|
|||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
|
function isCodemanTitle(title) {
|
||||||
|
return typeof title === 'string' && title.startsWith('codeman:');
|
||||||
|
}
|
||||||
|
|
||||||
function logSection(title) {
|
function logSection(title) {
|
||||||
console.log('\n' + '='.repeat(60));
|
console.log('\n' + '='.repeat(60));
|
||||||
console.log(` ${title}`);
|
console.log(` ${title}`);
|
||||||
@@ -88,7 +92,7 @@ async function main() {
|
|||||||
const page = await playwrightBrowser.newPage();
|
const page = await playwrightBrowser.newPage();
|
||||||
await page.goto(`http://localhost:${PORTS.playwright}`);
|
await page.goto(`http://localhost:${PORTS.playwright}`);
|
||||||
const title = await page.title();
|
const title = await page.title();
|
||||||
if (title !== 'Codeman') throw new Error(`Expected Codeman, got ${title}`);
|
if (!isCodemanTitle(title)) throw new Error(`Expected codeman:<hostname>, got ${title}`);
|
||||||
await page.close();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +153,7 @@ async function main() {
|
|||||||
const page = await puppeteerBrowser.newPage();
|
const page = await puppeteerBrowser.newPage();
|
||||||
await page.goto(`http://localhost:${PORTS.puppeteer}`);
|
await page.goto(`http://localhost:${PORTS.puppeteer}`);
|
||||||
const title = await page.title();
|
const title = await page.title();
|
||||||
if (title !== 'Codeman') throw new Error(`Expected Codeman, got ${title}`);
|
if (!isCodemanTitle(title)) throw new Error(`Expected codeman:<hostname>, got ${title}`);
|
||||||
await page.close();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -202,7 +206,7 @@ async function main() {
|
|||||||
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
const title = agentBrowserJson('get title');
|
const title = agentBrowserJson('get title');
|
||||||
agentBrowserAvailable = title.title === 'Codeman';
|
agentBrowserAvailable = isCodemanTitle(title.title);
|
||||||
console.log(' Browser launched');
|
console.log(' Browser launched');
|
||||||
|
|
||||||
// Test 1: Page load
|
// Test 1: Page load
|
||||||
@@ -210,7 +214,7 @@ async function main() {
|
|||||||
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
agentBrowser(`open http://localhost:${PORTS.agentBrowser}`);
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
const title = agentBrowserJson('get title');
|
const title = agentBrowserJson('get title');
|
||||||
if (title.title !== 'Codeman') throw new Error(`Expected Codeman, got ${title.title}`);
|
if (!isCodemanTitle(title.title)) throw new Error(`Expected codeman:<hostname>, got ${title.title}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test 2: Element selection
|
// Test 2: Element selection
|
||||||
|
|||||||
+3
-1
@@ -485,16 +485,18 @@ program
|
|||||||
.description('Start the web interface')
|
.description('Start the web interface')
|
||||||
.option('-p, --port <port>', 'Port to listen on', '3000')
|
.option('-p, --port <port>', 'Port to listen on', '3000')
|
||||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||||
|
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
const { startWebServer } = await import('./web/server.js');
|
const { startWebServer } = await import('./web/server.js');
|
||||||
const port = parseInt(options.port, 10);
|
const port = parseInt(options.port, 10);
|
||||||
const https = !!options.https;
|
const https = !!options.https;
|
||||||
|
const titleHostname = options.titleHostname;
|
||||||
const protocol = https ? 'https' : 'http';
|
const protocol = https ? 'https' : 'http';
|
||||||
|
|
||||||
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
|
console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const server = await startWebServer(port, https);
|
const server = await startWebServer(port, https, false, titleHostname);
|
||||||
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
|
console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`));
|
||||||
if (https) {
|
if (https) {
|
||||||
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||||
|
|||||||
@@ -291,11 +291,11 @@ class NotificationManager {
|
|||||||
this.titleFlashInterval = setInterval(() => {
|
this.titleFlashInterval = setInterval(() => {
|
||||||
this.titleFlashState = !this.titleFlashState;
|
this.titleFlashState = !this.titleFlashState;
|
||||||
document.title = this.titleFlashState
|
document.title = this.titleFlashState
|
||||||
? `\u26A0\uFE0F (${this.unreadCount}) Codeman`
|
? `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`
|
||||||
: this.originalTitle;
|
: this.originalTitle;
|
||||||
}, TITLE_FLASH_INTERVAL_MS);
|
}, TITLE_FLASH_INTERVAL_MS);
|
||||||
// Set immediately
|
// Set immediately
|
||||||
document.title = `\u26A0\uFE0F (${this.unreadCount}) Codeman`;
|
document.title = `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-4
@@ -37,7 +37,7 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { homedir } from 'node:os';
|
import { homedir, hostname as getHostname } from 'node:os';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { Session, type BackgroundTask } from '../session.js';
|
import { Session, type BackgroundTask } from '../session.js';
|
||||||
import type { ClaudeMode, SessionState } from '../types.js';
|
import type { ClaudeMode, SessionState } from '../types.js';
|
||||||
@@ -119,6 +119,10 @@ import {
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function escapeHtmlText(value: string): string {
|
||||||
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||||
|
}
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SESSIONS_LIST_CACHE_TTL,
|
SESSIONS_LIST_CACHE_TTL,
|
||||||
SCHEDULED_CLEANUP_INTERVAL,
|
SCHEDULED_CLEANUP_INTERVAL,
|
||||||
@@ -226,12 +230,18 @@ export class WebServer extends EventEmitter {
|
|||||||
teamRemoved: (config: unknown) => void;
|
teamRemoved: (config: unknown) => void;
|
||||||
taskUpdated: (data: unknown) => void;
|
taskUpdated: (data: unknown) => void;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
|
private readonly titleHostname: string;
|
||||||
|
private readonly windowTitle: string;
|
||||||
|
private readonly indexHtmlTemplate: string;
|
||||||
|
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false, titleHostname?: string) {
|
||||||
super();
|
super();
|
||||||
this.setMaxListeners(0);
|
this.setMaxListeners(0);
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.https = https;
|
this.https = https;
|
||||||
this.testMode = testMode;
|
this.testMode = testMode;
|
||||||
|
this.titleHostname = titleHostname || getHostname();
|
||||||
|
this.windowTitle = `codeman:${this.titleHostname}`;
|
||||||
|
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
|
||||||
|
|
||||||
if (https) {
|
if (https) {
|
||||||
const { key, cert } = getOrCreateSelfSignedCert();
|
const { key, cert } = getOrCreateSelfSignedCert();
|
||||||
@@ -526,6 +536,12 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
// Security headers + CORS
|
// Security headers + CORS
|
||||||
registerSecurityHeaders(this.app, this.https);
|
registerSecurityHeaders(this.app, this.https);
|
||||||
|
this.app.get('/', async (_req, reply) => {
|
||||||
|
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
|
||||||
|
});
|
||||||
|
this.app.get('/index.html', async (_req, reply) => {
|
||||||
|
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
|
||||||
|
});
|
||||||
// Service worker must never be cached — browsers check for SW updates on navigation
|
// Service worker must never be cached — browsers check for SW updates on navigation
|
||||||
this.app.get('/sw.js', async (_req, reply) => {
|
this.app.get('/sw.js', async (_req, reply) => {
|
||||||
return reply
|
return reply
|
||||||
@@ -922,6 +938,13 @@ export class WebServer extends EventEmitter {
|
|||||||
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderIndexHtml(): string {
|
||||||
|
return this.indexHtmlTemplate.replace(
|
||||||
|
'<title>Codeman</title>',
|
||||||
|
`<title>${escapeHtmlText(this.windowTitle)}</title>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async setupSessionListeners(session: Session): Promise<void> {
|
private async setupSessionListeners(session: Session): Promise<void> {
|
||||||
// Create run summary tracker for this session
|
// Create run summary tracker for this session
|
||||||
const summaryTracker = new RunSummaryTracker(session.id, session.name);
|
const summaryTracker = new RunSummaryTracker(session.id, session.name);
|
||||||
@@ -1970,9 +1993,10 @@ export class WebServer extends EventEmitter {
|
|||||||
export async function startWebServer(
|
export async function startWebServer(
|
||||||
port: number = 3000,
|
port: number = 3000,
|
||||||
https: boolean = false,
|
https: boolean = false,
|
||||||
testMode: boolean = false
|
testMode: boolean = false,
|
||||||
|
titleHostname?: string
|
||||||
): Promise<WebServer> {
|
): Promise<WebServer> {
|
||||||
const server = new WebServer(port, https, testMode);
|
const server = new WebServer(port, https, testMode, titleHostname);
|
||||||
await server.start();
|
await server.start();
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ async function waitForElement(selector: string, timeout = 10000): Promise<boolea
|
|||||||
try {
|
try {
|
||||||
const count = browserJson<{ count: number }>(`get count "${selector}"`);
|
const count = browserJson<{ count: number }>(`get count "${selector}"`);
|
||||||
if (count.count > 0) return true;
|
if (count.count > 0) return true;
|
||||||
} catch { /* retry */ }
|
} catch {
|
||||||
await new Promise(r => setTimeout(r, 500));
|
/* retry */
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,9 @@ function isVisible(selector: string): boolean {
|
|||||||
function closeBrowser() {
|
function closeBrowser() {
|
||||||
try {
|
try {
|
||||||
browser('close');
|
browser('close');
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('File Link Click Tests', () => {
|
describe('File Link Click Tests', () => {
|
||||||
@@ -95,14 +99,14 @@ describe('File Link Click Tests', () => {
|
|||||||
|
|
||||||
server = new WebServer(TEST_PORT, false, true);
|
server = new WebServer(TEST_PORT, false, true);
|
||||||
await server.start();
|
await server.start();
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
// Test if browser is available
|
// Test if browser is available
|
||||||
try {
|
try {
|
||||||
browser(`open ${baseUrl}`);
|
browser(`open ${baseUrl}`);
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
const title = browserJson<{ title: string }>('get title');
|
const title = browserJson<{ title: string }>('get title');
|
||||||
browserAvailable = title.title === 'Codeman';
|
browserAvailable = title.title.startsWith('codeman:');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Browser not available, skipping browser tests:', (e as Error).message);
|
console.warn('Browser not available, skipping browser tests:', (e as Error).message);
|
||||||
browserAvailable = false;
|
browserAvailable = false;
|
||||||
@@ -114,14 +118,18 @@ describe('File Link Click Tests', () => {
|
|||||||
for (const sessionId of createdSessions) {
|
for (const sessionId of createdSessions) {
|
||||||
try {
|
try {
|
||||||
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await server.stop();
|
await server.stop();
|
||||||
|
|
||||||
// Cleanup test directory
|
// Cleanup test directory
|
||||||
try {
|
try {
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
} catch { /* ignore */ }
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
it('should create shell session and display terminal output', async () => {
|
it('should create shell session and display terminal output', async () => {
|
||||||
@@ -142,7 +150,7 @@ describe('File Link Click Tests', () => {
|
|||||||
createdSessions.push(data.session.id);
|
createdSessions.push(data.session.id);
|
||||||
|
|
||||||
// Wait for session to appear in UI
|
// Wait for session to appear in UI
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
|
||||||
// Check that terminal is visible
|
// Check that terminal is visible
|
||||||
const terminalExists = await waitForElement('.xterm-screen', 5000);
|
const terminalExists = await waitForElement('.xterm-screen', 5000);
|
||||||
@@ -167,7 +175,7 @@ describe('File Link Click Tests', () => {
|
|||||||
body: JSON.stringify({ input: command + '\r' }),
|
body: JSON.stringify({ input: command + '\r' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
|
||||||
// Check if xterm contains the file path
|
// Check if xterm contains the file path
|
||||||
// The xterm link provider should detect "tail -f /path/to/file" pattern
|
// The xterm link provider should detect "tail -f /path/to/file" pattern
|
||||||
@@ -204,7 +212,7 @@ describe('File Link Click Tests', () => {
|
|||||||
// Click somewhere in the terminal where the tail -f line should be
|
// Click somewhere in the terminal where the tail -f line should be
|
||||||
// This is approximate - the link detection works on hover
|
// This is approximate - the link detection works on hover
|
||||||
browser('click ".xterm-screen"');
|
browser('click ".xterm-screen"');
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Click failed:', e);
|
console.log('Click failed:', e);
|
||||||
}
|
}
|
||||||
@@ -243,10 +251,10 @@ describe('File Link Click Tests', () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ input: `echo "${pattern}"\r` }),
|
body: JSON.stringify({ input: `echo "${pattern}"\r` }),
|
||||||
});
|
});
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
// Verify patterns appear in terminal
|
// Verify patterns appear in terminal
|
||||||
const terminalText = getText('.xterm-screen');
|
const terminalText = getText('.xterm-screen');
|
||||||
@@ -255,11 +263,12 @@ describe('File Link Click Tests', () => {
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
|
|
||||||
it('should match file paths with various command patterns', () => {
|
it('should match file paths with various command patterns', () => {
|
||||||
// Unit test for pattern matching logic - runs without browser
|
// Unit test for pattern matching logic - runs without browser
|
||||||
// Pattern matches: tail -f /path, grep pattern /path, cat -n /path
|
// Pattern matches: tail -f /path, grep pattern /path, cat -n /path
|
||||||
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||||
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
const extPattern =
|
||||||
|
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||||
|
|
||||||
// Test cmdPattern
|
// Test cmdPattern
|
||||||
@@ -309,13 +318,14 @@ it('should match file paths with various command patterns', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT match invalid or unsafe paths', () => {
|
it('should NOT match invalid or unsafe paths', () => {
|
||||||
const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
const extPattern =
|
||||||
|
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||||
|
|
||||||
const invalidCases = [
|
const invalidCases = [
|
||||||
'This is just text without paths',
|
'This is just text without paths',
|
||||||
'./relative/path.log', // relative path
|
'./relative/path.log', // relative path
|
||||||
'C:\\Windows\\path.log', // windows path
|
'C:\\Windows\\path.log', // windows path
|
||||||
'/usr/bin/something.log', // /usr not in allowed prefixes
|
'/usr/bin/something.log', // /usr not in allowed prefixes
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const line of invalidCases) {
|
for (const line of invalidCases) {
|
||||||
@@ -337,7 +347,7 @@ it('should match file paths with various command patterns', () => {
|
|||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.success).toBe(true);
|
expect(data.success).toBe(true);
|
||||||
sessionId = data.sessionId; // quick-start returns sessionId directly
|
sessionId = data.sessionId; // quick-start returns sessionId directly
|
||||||
createdSessions.push(sessionId);
|
createdSessions.push(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user