mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-22 08:16:09 +08:00
Dashboard with a sweet background. Also realtime assignment / policy view.
This commit is contained in:
parent
3f5cac4342
commit
09bb7874f8
|
@ -7,6 +7,8 @@ const Package = require('./package');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
global.Promise = require('bluebird');
|
||||||
|
|
||||||
const server = new Hapi.Server();
|
const server = new Hapi.Server();
|
||||||
server.connection({ port: process.env.PORT || 5100 });
|
server.connection({ port: process.env.PORT || 5100 });
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
const Imap = require('imap');
|
const Imap = require('imap');
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const Promise = require('bluebird');
|
|
||||||
|
|
||||||
const Capabilities = {
|
const Capabilities = {
|
||||||
Gmail: 'X-GM-EXT-1',
|
Gmail: 'X-GM-EXT-1',
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
global.Promise = require('bluebird');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
DatabaseConnector: require('./database-connector'),
|
DatabaseConnector: require('./database-connector'),
|
||||||
PubsubConnector: require('./pubsub-connector'),
|
PubsubConnector: require('./pubsub-connector'),
|
||||||
IMAPConnection: require('./imap-connection'),
|
IMAPConnection: require('./imap-connection'),
|
||||||
SyncPolicy: require('./sync-policy'),
|
SyncPolicy: require('./sync-policy'),
|
||||||
|
SchedulerUtils: require('./scheduler-utils'),
|
||||||
Config: require(`./config/${process.env.ENV || 'development'}`),
|
Config: require(`./config/${process.env.ENV || 'development'}`),
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
const Rx = require('rx')
|
const Rx = require('rx')
|
||||||
const bluebird = require('bluebird')
|
|
||||||
const redis = require("redis");
|
const redis = require("redis");
|
||||||
|
|
||||||
const SyncPolicy = require('./sync-policy');
|
const SyncPolicy = require('./sync-policy');
|
||||||
|
|
||||||
bluebird.promisifyAll(redis.RedisClient.prototype);
|
Promise.promisifyAll(redis.RedisClient.prototype);
|
||||||
bluebird.promisifyAll(redis.Multi.prototype);
|
Promise.promisifyAll(redis.Multi.prototype);
|
||||||
|
|
||||||
class PubsubConnector {
|
class PubsubConnector {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -32,7 +31,7 @@ class PubsubConnector {
|
||||||
}
|
}
|
||||||
|
|
||||||
channelForAccountDeltas(accountId) {
|
channelForAccountDeltas(accountId) {
|
||||||
return `a-${accountId}-deltas`;
|
return `deltas-${accountId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared channel
|
// Shared channel
|
||||||
|
|
29
packages/nylas-core/scheduler-utils.js
Normal file
29
packages/nylas-core/scheduler-utils.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
const ACCOUNTS_UNCLAIMED = 'accounts:unclaimed';
|
||||||
|
const ACCOUNTS_CLAIMED_PREFIX = 'accounts:id-';
|
||||||
|
const ACCOUNTS_FOR = (id) => `${ACCOUNTS_CLAIMED_PREFIX}${id}`;
|
||||||
|
const HEARTBEAT_FOR = (id) => `heartbeat:${id}`;
|
||||||
|
const HEARTBEAT_EXPIRES = 30; // 2 min in prod?
|
||||||
|
const CLAIM_DURATION = 10 * 60 * 1000; // 2 hours on prod?
|
||||||
|
|
||||||
|
const PubsubConnector = require('./pubsub-connector')
|
||||||
|
|
||||||
|
const forEachAccountList = (forEachCallback) => {
|
||||||
|
const client = PubsubConnector.broadcastClient();
|
||||||
|
return Promise.each(client.keysAsync(`accounts:*`), (key) => {
|
||||||
|
const processId = key.replace('accounts:', '');
|
||||||
|
return client.lrangeAsync(key, 0, 20000).then((foundIds) =>
|
||||||
|
forEachCallback(processId, foundIds)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ACCOUNTS_UNCLAIMED,
|
||||||
|
ACCOUNTS_CLAIMED_PREFIX,
|
||||||
|
ACCOUNTS_FOR,
|
||||||
|
HEARTBEAT_FOR,
|
||||||
|
HEARTBEAT_EXPIRES,
|
||||||
|
CLAIM_DURATION,
|
||||||
|
|
||||||
|
forEachAccountList,
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
class SyncPolicy {
|
class SyncPolicy {
|
||||||
static defaultPolicy() {
|
static defaultPolicy() {
|
||||||
return {
|
return {
|
||||||
afterSync: 'close',
|
afterSync: 'idle',
|
||||||
interval: 120 * 1000,
|
interval: 120 * 1000,
|
||||||
folderSyncOptions: {
|
folderSyncOptions: {
|
||||||
deepFolderScan: 10 * 60 * 1000,
|
deepFolderScan: 10 * 60 * 1000,
|
||||||
|
|
75
packages/nylas-dashboard/app.js
Normal file
75
packages/nylas-dashboard/app.js
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
const Hapi = require('hapi');
|
||||||
|
const HapiWebSocket = require('hapi-plugin-websocket');
|
||||||
|
const Inert = require('inert');
|
||||||
|
const {DatabaseConnector, PubsubConnector, SchedulerUtils} = require(`nylas-core`);
|
||||||
|
const {forEachAccountList} = SchedulerUtils;
|
||||||
|
|
||||||
|
global.Promise = require('bluebird');
|
||||||
|
|
||||||
|
const server = new Hapi.Server();
|
||||||
|
server.connection({ port: process.env.PORT || 5101 });
|
||||||
|
|
||||||
|
DatabaseConnector.forShared().then(({Account}) => {
|
||||||
|
server.register([HapiWebSocket, Inert], () => {
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
path: "/accounts",
|
||||||
|
config: {
|
||||||
|
plugins: {
|
||||||
|
websocket: {
|
||||||
|
only: true,
|
||||||
|
connect: (wss, ws) => {
|
||||||
|
Account.findAll().then((accts) => {
|
||||||
|
accts.forEach((acct) => {
|
||||||
|
ws.send(JSON.stringify({ cmd: "ACCOUNT", payload: acct }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.redis = PubsubConnector.buildClient();
|
||||||
|
this.redis.on('pmessage', (pattern, channel) => {
|
||||||
|
Account.find({where: {id: channel.replace('a-', '')}}).then((acct) => {
|
||||||
|
ws.send(JSON.stringify({ cmd: "ACCOUNT", payload: acct }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.redis.psubscribe(PubsubConnector.channelForAccount('*'));
|
||||||
|
this.assignmentsInterval = setInterval(() => {
|
||||||
|
const assignments = {};
|
||||||
|
forEachAccountList((identity, accountIds) => {
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
assignments[accountId] = identity;
|
||||||
|
}
|
||||||
|
}).then(() =>
|
||||||
|
ws.send(JSON.stringify({ cmd: "ASSIGNMENTS", payload: assignments}))
|
||||||
|
)
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
disconnect: () => {
|
||||||
|
clearInterval(this.assignmentsInterval);
|
||||||
|
this.redis.quit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: (request, reply) => {
|
||||||
|
if (request.payload.cmd === "PING") {
|
||||||
|
reply(JSON.stringify({ result: "PONG" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/{param*}',
|
||||||
|
handler: {
|
||||||
|
directory: {
|
||||||
|
path: 'public',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.start((startErr) => {
|
||||||
|
if (startErr) { throw startErr; }
|
||||||
|
console.log('Server running at:', server.info.uri);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
17
packages/nylas-dashboard/package.json
Normal file
17
packages/nylas-dashboard/package.json
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "nylas-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"hapi": "^13.4.1",
|
||||||
|
"hapi-plugin-websocket": "^0.9.2",
|
||||||
|
"inert": "^4.0.0",
|
||||||
|
"nylas-core": "0.x.x"
|
||||||
|
}
|
||||||
|
}
|
17
packages/nylas-dashboard/public/css/app.css
Normal file
17
packages/nylas-dashboard/public/css/app.css
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
body {
|
||||||
|
background-image: -webkit-linear-gradient(top, rgba(232, 244, 250, 0.6), rgba(231, 231, 233, 1)), url(http://news.nationalgeographic.com/content/dam/news/2015/12/13/BookTalk%20K2/01BookTalkK2.jpg);
|
||||||
|
background-size: cover;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account {
|
||||||
|
display:inline-block;
|
||||||
|
width: 300px;
|
||||||
|
height: 100px;
|
||||||
|
background-color: white;
|
||||||
|
padding:15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account h3 {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
}
|
13
packages/nylas-dashboard/public/index.html
Normal file
13
packages/nylas-dashboard/public/index.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="/js/react.js"></script>
|
||||||
|
<script src="/js/react-dom.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.23/browser.min.js"></script>
|
||||||
|
<script src="/js/app.jsx" type="text/babel"></script>
|
||||||
|
<link rel='stylesheet' type="text/css" href="./css/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
91
packages/nylas-dashboard/public/js/app.jsx
Normal file
91
packages/nylas-dashboard/public/js/app.jsx
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
/* eslint react/react-in-jsx-scope: 0*/
|
||||||
|
|
||||||
|
class Account extends React.Component {
|
||||||
|
propTypes: {
|
||||||
|
account: React.PropTypes.object,
|
||||||
|
assignment: React.PropTypes.string,
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {account, assignment} = this.props;
|
||||||
|
return (
|
||||||
|
<div className="account">
|
||||||
|
<h3>{account.email_address}</h3>
|
||||||
|
<strong>{assignment}</strong>
|
||||||
|
<div>Sync Interval: {account.sync_policy.interval}ms</div>
|
||||||
|
<div>Sync Idle Behavior: {account.sync_policy.afterSync}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Root extends React.Component {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.state = {
|
||||||
|
accounts: {},
|
||||||
|
assignments: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
let url = null;
|
||||||
|
if (window.location.protocol === "https:") {
|
||||||
|
url = `wss://${window.location.host}/accounts`;
|
||||||
|
} else {
|
||||||
|
url = `ws://${window.location.host}/accounts`;
|
||||||
|
}
|
||||||
|
this.websocket = new WebSocket(url);
|
||||||
|
this.websocket.onopen = () => {
|
||||||
|
this.websocket.send("Message to send");
|
||||||
|
};
|
||||||
|
this.websocket.onmessage = (evt) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data);
|
||||||
|
if (msg.cmd === 'ACCOUNT') {
|
||||||
|
this.onReceivedAccount(msg.payload);
|
||||||
|
}
|
||||||
|
if (msg.cmd === 'ASSIGNMENTS') {
|
||||||
|
this.onReceivedAssignments(msg.payload);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.websocket.onclose = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onReceivedAssignments(assignments) {
|
||||||
|
this.setState({assignments})
|
||||||
|
}
|
||||||
|
|
||||||
|
onReceivedAccount(account) {
|
||||||
|
const accounts = Object.assign({}, this.state.accounts);
|
||||||
|
accounts[account.id] = account;
|
||||||
|
this.setState({accounts});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
Object.keys(this.state.accounts).sort((a, b) => a.compare(b)).map((key) =>
|
||||||
|
<Account
|
||||||
|
key={key}
|
||||||
|
assignment={this.state.assignments[key]}
|
||||||
|
account={this.state.accounts[key]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<Root />,
|
||||||
|
document.getElementById('root')
|
||||||
|
);
|
42
packages/nylas-dashboard/public/js/react-dom.js
vendored
Normal file
42
packages/nylas-dashboard/public/js/react-dom.js
vendored
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* ReactDOM v15.1.0
|
||||||
|
*
|
||||||
|
* Copyright 2013-present, Facebook, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the BSD-style license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree. An additional grant
|
||||||
|
* of patent rights can be found in the PATENTS file in the same directory.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
// Based off https://github.com/ForbesLindesay/umd/blob/master/template.js
|
||||||
|
;(function(f) {
|
||||||
|
// CommonJS
|
||||||
|
if (typeof exports === "object" && typeof module !== "undefined") {
|
||||||
|
module.exports = f(require('react'));
|
||||||
|
|
||||||
|
// RequireJS
|
||||||
|
} else if (typeof define === "function" && define.amd) {
|
||||||
|
define(['react'], f);
|
||||||
|
|
||||||
|
// <script>
|
||||||
|
} else {
|
||||||
|
var g;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
g = window;
|
||||||
|
} else if (typeof global !== "undefined") {
|
||||||
|
g = global;
|
||||||
|
} else if (typeof self !== "undefined") {
|
||||||
|
g = self;
|
||||||
|
} else {
|
||||||
|
// works providing we're not in "use strict";
|
||||||
|
// needed for Java 8 Nashorn
|
||||||
|
// see https://github.com/facebook/react/issues/3037
|
||||||
|
g = this;
|
||||||
|
}
|
||||||
|
g.ReactDOM = f(g.React);
|
||||||
|
}
|
||||||
|
|
||||||
|
})(function(React) {
|
||||||
|
return React.__SECRET_DOM_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
|
||||||
|
});
|
12
packages/nylas-dashboard/public/js/react-dom.min.js
vendored
Normal file
12
packages/nylas-dashboard/public/js/react-dom.min.js
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* ReactDOM v15.1.0
|
||||||
|
*
|
||||||
|
* Copyright 2013-present, Facebook, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the BSD-style license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree. An additional grant
|
||||||
|
* of patent rights can be found in the PATENTS file in the same directory.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e(require("react"));else if("function"==typeof define&&define.amd)define(["react"],e);else{var f;f="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,f.ReactDOM=e(f.React)}}(function(e){return e.__SECRET_DOM_DO_NOT_USE_OR_YOU_WILL_BE_FIRED});
|
19599
packages/nylas-dashboard/public/js/react.js
vendored
Normal file
19599
packages/nylas-dashboard/public/js/react.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
16
packages/nylas-dashboard/public/js/react.min.js
vendored
Normal file
16
packages/nylas-dashboard/public/js/react.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,6 +1,8 @@
|
||||||
const {DatabaseConnector} = require(`nylas-core`)
|
const {DatabaseConnector} = require(`nylas-core`)
|
||||||
const {processors} = require('./processors')
|
const {processors} = require('./processors')
|
||||||
|
|
||||||
|
global.Promise = require('bluebird');
|
||||||
|
|
||||||
// List of the attributes of Message that the processor should be allowed to change.
|
// List of the attributes of Message that the processor should be allowed to change.
|
||||||
// The message may move between folders, get starred, etc. while it's being
|
// The message may move between folders, get starred, etc. while it's being
|
||||||
// processed, and it shouldn't overwrite changes to those fields.
|
// processed, and it shouldn't overwrite changes to those fields.
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const SyncWorker = require('./sync-worker');
|
const SyncWorker = require('./sync-worker');
|
||||||
const {DatabaseConnector, PubsubConnector} = require(`nylas-core`)
|
const {DatabaseConnector, PubsubConnector, SchedulerUtils} = require(`nylas-core`)
|
||||||
|
|
||||||
const CPU_COUNT = os.cpus().length;
|
|
||||||
const IDENTITY = `${os.hostname()}-${process.pid}`;
|
const IDENTITY = `${os.hostname()}-${process.pid}`;
|
||||||
|
|
||||||
const ACCOUNTS_UNCLAIMED = 'accounts:unclaimed';
|
const {
|
||||||
const ACCOUNTS_CLAIMED_PREFIX = 'accounts:id-';
|
ACCOUNTS_FOR,
|
||||||
const ACCOUNTS_FOR = (id) => `${ACCOUNTS_CLAIMED_PREFIX}${id}`;
|
ACCOUNTS_UNCLAIMED,
|
||||||
const HEARTBEAT_FOR = (id) => `heartbeat:${id}`;
|
ACCOUNTS_CLAIMED_PREFIX,
|
||||||
const HEARTBEAT_EXPIRES = 30; // 2 min in prod?
|
HEARTBEAT_FOR,
|
||||||
const CLAIM_DURATION = 10 * 60 * 1000; // 2 hours on prod?
|
HEARTBEAT_EXPIRES,
|
||||||
|
CLAIM_DURATION,
|
||||||
|
forEachAccountList,
|
||||||
|
} = SchedulerUtils;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Accounts ALWAYS exist in either `accounts:unclaimed` or an `accounts:{id}` list.
|
Accounts ALWAYS exist in either `accounts:unclaimed` or an `accounts:{id}` list.
|
||||||
|
@ -61,7 +63,7 @@ class SyncProcessManager {
|
||||||
client.setAsync(key, Date.now()).then(() =>
|
client.setAsync(key, Date.now()).then(() =>
|
||||||
client.expireAsync(key, HEARTBEAT_EXPIRES)
|
client.expireAsync(key, HEARTBEAT_EXPIRES)
|
||||||
).then(() =>
|
).then(() =>
|
||||||
console.log("ProcessManager: Published heartbeat.")
|
console.log("ProcessManager: ❤")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,11 +87,10 @@ class SyncProcessManager {
|
||||||
|
|
||||||
console.log("ProcessManager: Starting scan for accountIds in database that are not present in Redis.")
|
console.log("ProcessManager: Starting scan for accountIds in database that are not present in Redis.")
|
||||||
|
|
||||||
return Promise.each(client.keysAsync(`accounts:*`), (key) =>
|
return forEachAccountList((foundProcessIdentity, foundIds) => {
|
||||||
client.lrangeAsync(key, 0, 20000).then((foundIds) => {
|
|
||||||
unseenIds = unseenIds.filter((a) => !foundIds.includes(`${a}`))
|
unseenIds = unseenIds.filter((a) => !foundIds.includes(`${a}`))
|
||||||
})
|
})
|
||||||
).finally(() => {
|
.finally(() => {
|
||||||
if (unseenIds.length === 0) {
|
if (unseenIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -158,9 +158,9 @@ class SyncWorker {
|
||||||
.then(this.fetchCategoryList.bind(this))
|
.then(this.fetchCategoryList.bind(this))
|
||||||
.then(this.syncbackMessageActions.bind(this))
|
.then(this.syncbackMessageActions.bind(this))
|
||||||
.then(this.fetchMessagesInCategory.bind(this))
|
.then(this.fetchMessagesInCategory.bind(this))
|
||||||
.then(() => { this._lastSyncTime = Date.now() })
|
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
this._lastSyncTime = Date.now()
|
||||||
this.onSyncDidComplete();
|
this.onSyncDidComplete();
|
||||||
this.scheduleNextSync();
|
this.scheduleNextSync();
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue