feat(alerts): ntfy.sh notification channel (v0.21.0-beta)
Closes #93. Routing push notifications through email ingestion was a workaround. ntfy is now a first-class channel alongside Slack, Discord, Telegram and email, using the same alert_channels row and the same dispatch switch. Built to cover the whole plausible configuration space rather than waiting on the reporter's answers, so no config migration is needed once they arrive: - server_url is operator-supplied and defaults to https://ntfy.sh, so a self-hosted instance works — the likely case for infrastructure alerting - both of ntfy's auth schemes: an access token (preferred; scopable and revocable) or username/password, with none required for an open topic - optional click_url deep-links each notification to the device it refers to Published as JSON to the server root rather than POST /{topic} with header metadata: the header form is ASCII-only and these titles carry per-event emoji. Severity maps onto ntfy's priority scale so the channel stays worth being woken by — 4 (breaks through do-not-disturb) for device_offline, log_error and CPU/memory pressure; 2 for recoveries and discoveries; 3 for everything else. The event type is attached as a tag so a client can be filtered to wake only for chosen events without needing a channel per event. Secrets follow the existing pattern: token and password are added to SENSITIVE_KEYS, so they are masked on read and the mask placeholder is restored to the stored value on write rather than overwriting it. The type column is a CHECK constraint rather than a lookup table, so widening it means dropping and recreating the constraint; done idempotently for existing installs. Note on trust: server_url lets an admin point the channel at an internal address. That is the same exposure the existing Slack and Discord webhook URLs already have, and only an admin can configure any of them. Restricting it would break the self-hosted case this feature is mostly for. Verified against the real ntfy.sh — a test alert arrived with the correct title, emoji, body, priority, tag and click URL; masking verified to hide the token on read while preserving it across a save that did not retype it. 13 unit tests cover priority mapping, both auth schemes, URL normalisation, and rejection of a missing topic or non-http server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b03ea87f11
commit
19617e8357
@@ -2,7 +2,7 @@
|
||||
|
||||
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -327,7 +327,16 @@ Configurable alert rules with cooldown periods:
|
||||
- New device discovered
|
||||
- Configuration changed (drift detection)
|
||||
|
||||
Alert delivery channels: **Email**, **Slack**, **Discord**, **Telegram**
|
||||
Alert delivery channels: **Email**, **Slack**, **Discord**, **Telegram**, **ntfy**
|
||||
|
||||
**ntfy** (`https://ntfy.sh` or your own instance) is configured with a topic and, optionally, an
|
||||
access token or username/password — a publicly-writable topic lets anyone spoof your alerts, so
|
||||
protecting it is worth the extra field. Alert severity maps onto ntfy's priority scale so the
|
||||
notifications that mean something is *down* (device offline, log errors, CPU/memory pressure) arrive
|
||||
at priority 4 and break through a phone's do-not-disturb, recoveries and discoveries arrive quietly
|
||||
at 2, and everything else sits at the default 3. Each notification is tagged with its event type, so
|
||||
a client can be filtered to wake only for the events you care about. Set the optional **Manager URL**
|
||||
and notifications become tappable, opening the device they refer to.
|
||||
|
||||
### Maintenance Windows
|
||||
- Schedule planned downtime windows per device or group of devices to suppress alerts automatically
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.20.1-beta",
|
||||
"version": "0.21.0-beta",
|
||||
"description": "Mikrotik Network Management Platform - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -283,7 +283,7 @@ CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
CREATE TABLE IF NOT EXISTS alert_channels (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('email','slack','discord','telegram')),
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('email','slack','discord','telegram','ntfy')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
@@ -498,6 +498,12 @@ CREATE TABLE IF NOT EXISTS device_change_guards (
|
||||
CREATE INDEX IF NOT EXISTS idx_change_guards_device ON device_change_guards(device_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_change_guards_pending ON device_change_guards(status, expires_at);
|
||||
|
||||
-- ntfy.sh notification channel (github issue #93). The channel type is a CHECK
|
||||
-- constraint rather than a lookup table, so widening it means replacing it.
|
||||
ALTER TABLE alert_channels DROP CONSTRAINT IF EXISTS alert_channels_type_check;
|
||||
ALTER TABLE alert_channels ADD CONSTRAINT alert_channels_type_check
|
||||
CHECK (type IN ('email','slack','discord','telegram','ntfy'));
|
||||
|
||||
-- Change Guard ledger: committed_at used to be stamped for every terminal status,
|
||||
-- so an abandoned or failed guard read as though the change had been kept. It now
|
||||
-- means what it says, and resolved_at records when the guard finished either way.
|
||||
|
||||
@@ -81,7 +81,7 @@ router.post('/channels', requireWrite, async (req, res) => {
|
||||
if (!name || !type) {
|
||||
return res.status(400).json({ error: 'name and type are required' });
|
||||
}
|
||||
const validTypes = ['email', 'slack', 'discord', 'telegram'];
|
||||
const validTypes = ['email', 'slack', 'discord', 'telegram', 'ntfy'];
|
||||
if (!validTypes.includes(type)) {
|
||||
return res.status(400).json({ error: `type must be one of: ${validTypes.join(', ')}` });
|
||||
}
|
||||
@@ -171,6 +171,8 @@ const SENSITIVE_KEYS: Record<string, string[]> = {
|
||||
slack: [],
|
||||
discord: [],
|
||||
telegram: ['bot_token'],
|
||||
// ntfy accepts either an access token or basic auth; both are secrets.
|
||||
ntfy: ['token', 'password'],
|
||||
};
|
||||
|
||||
function maskConfig(type: string, config: Record<string, unknown>): Record<string, unknown> {
|
||||
|
||||
@@ -33,7 +33,7 @@ interface AlertRule {
|
||||
interface AlertChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'email' | 'slack' | 'discord' | 'telegram';
|
||||
type: 'email' | 'slack' | 'discord' | 'telegram' | 'ntfy';
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
@@ -203,6 +203,7 @@ export class AlertService {
|
||||
case 'slack': await this.sendSlack(ch.config, eventType, message, ctx); break;
|
||||
case 'discord': await this.sendDiscord(ch.config, eventType, message, ctx); break;
|
||||
case 'telegram': await this.sendTelegram(ch.config, eventType, message, ctx); break;
|
||||
case 'ntfy': await this.sendNtfy(ch.config, eventType, message, ctx); break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,9 +330,96 @@ export class AlertService {
|
||||
await this.postJson(url, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* ntfy (https://ntfy.sh, or a self-hosted instance).
|
||||
*
|
||||
* Published as JSON to the server root rather than `POST /{topic}` with header
|
||||
* metadata: the header form requires ASCII, and the titles here carry per-event
|
||||
* emoji. The JSON body is UTF-8 and keeps the emoji intact.
|
||||
*
|
||||
* `server_url` is operator-supplied so self-hosted instances work — which is the
|
||||
* common case for infrastructure alerting. That is the same trust model as the
|
||||
* existing Slack and Discord webhook URLs: an admin can point any of them at an
|
||||
* internal address, and only an admin can configure them.
|
||||
*/
|
||||
private async sendNtfy(
|
||||
cfg: Record<string, unknown>,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: AlertContext
|
||||
): Promise<void> {
|
||||
const topic = (cfg.topic as string | undefined)?.trim();
|
||||
if (!topic) throw new Error('ntfy channel missing topic');
|
||||
|
||||
const serverUrl = ((cfg.server_url as string | undefined)?.trim() || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(serverUrl);
|
||||
} catch {
|
||||
throw new Error(`ntfy channel has an invalid server_url: ${serverUrl}`);
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('ntfy server_url must be http or https');
|
||||
}
|
||||
|
||||
const label = EVENT_LABELS[eventType] ?? eventType;
|
||||
const emoji = EVENT_EMOJI[eventType] ?? '\u{1F514}';
|
||||
|
||||
const lines = [message];
|
||||
if (ctx.deviceName) lines.push(`Device: ${ctx.deviceName}`);
|
||||
if (ctx.details) lines.push(ctx.details);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
topic,
|
||||
title: `${emoji} ${label}`,
|
||||
message: lines.join('\n'),
|
||||
priority: this.ntfyPriority(eventType),
|
||||
// The event type doubles as a filterable tag, so a phone can be set to only
|
||||
// wake for device_offline without the manager needing per-event channels.
|
||||
tags: [eventType],
|
||||
};
|
||||
|
||||
// Optional deep link back into the manager, when it knows its own address.
|
||||
const clickBase = (cfg.click_url as string | undefined)?.trim().replace(/\/+$/, '');
|
||||
if (clickBase) {
|
||||
body.click = ctx.deviceId != null ? `${clickBase}/devices/${ctx.deviceId}` : clickBase;
|
||||
}
|
||||
|
||||
await this.postJson(serverUrl, JSON.stringify(body), this.ntfyAuthHeaders(cfg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Access token if present, otherwise basic auth. Infrastructure alerts should not
|
||||
* require a world-writable topic, so both of ntfy's schemes are supported and the
|
||||
* token is preferred — it can be scoped and revoked without changing a password.
|
||||
*/
|
||||
private ntfyAuthHeaders(cfg: Record<string, unknown>): Record<string, string> {
|
||||
const token = (cfg.token as string | undefined)?.trim();
|
||||
if (token) return { Authorization: `Bearer ${token}` };
|
||||
|
||||
const username = (cfg.username as string | undefined)?.trim();
|
||||
const password = (cfg.password as string | undefined) ?? '';
|
||||
if (username) {
|
||||
const encoded = Buffer.from(`${username}:${password}`).toString('base64');
|
||||
return { Authorization: `Basic ${encoded}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* ntfy priority runs 1 (min) to 5 (max); 4 and above bypasses a phone's
|
||||
* do-not-disturb. Reserve that for things that mean something is down, so the
|
||||
* channel stays worth being woken by.
|
||||
*/
|
||||
private ntfyPriority(eventType: string): number {
|
||||
if (['device_offline', 'log_error', 'high_cpu', 'high_memory'].includes(eventType)) return 4;
|
||||
if (['device_online', 'device_discovered'].includes(eventType)) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private postJson(url: string, body: string): Promise<void> {
|
||||
private postJson(url: string, body: string, extraHeaders: Record<string, string> = {}): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
@@ -344,6 +432,7 @@ export class AlertService {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
...extraHeaders,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -148,3 +148,147 @@ describe('discordColor', () => {
|
||||
expect(discordColor('device_offline')).toBe(0xef4444);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ntfy (issue #93) ─────────────────────────────────────────────────────────
|
||||
|
||||
type NtfyService = {
|
||||
ntfyPriority(eventType: string): number;
|
||||
ntfyAuthHeaders(cfg: Record<string, unknown>): Record<string, string>;
|
||||
sendNtfy(
|
||||
cfg: Record<string, unknown>,
|
||||
eventType: string,
|
||||
message: string,
|
||||
ctx: { deviceId?: number; deviceName?: string; details?: string }
|
||||
): Promise<void>;
|
||||
postJson(url: string, body: string, headers?: Record<string, string>): Promise<void>;
|
||||
};
|
||||
|
||||
const asNtfy = (s: AlertService) => s as unknown as NtfyService;
|
||||
|
||||
describe('ntfyPriority', () => {
|
||||
const p = (et: string) => asNtfy(new AlertService()).ntfyPriority(et);
|
||||
|
||||
it('raises priority above do-not-disturb only for things that are down', () => {
|
||||
expect(p('device_offline')).toBe(4);
|
||||
expect(p('log_error')).toBe(4);
|
||||
expect(p('high_cpu')).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps recovery and discovery quiet', () => {
|
||||
expect(p('device_online')).toBe(2);
|
||||
expect(p('device_discovered')).toBe(2);
|
||||
});
|
||||
|
||||
it('leaves everything else at the default', () => {
|
||||
expect(p('cert_expiry')).toBe(3);
|
||||
expect(p('config_drift')).toBe(3);
|
||||
expect(p('something_new')).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ntfyAuthHeaders', () => {
|
||||
const h = (cfg: Record<string, unknown>) => asNtfy(new AlertService()).ntfyAuthHeaders(cfg);
|
||||
|
||||
it('prefers an access token over basic auth', () => {
|
||||
expect(h({ token: 'tk_abc', username: 'u', password: 'p' }))
|
||||
.toEqual({ Authorization: 'Bearer tk_abc' });
|
||||
});
|
||||
|
||||
it('falls back to basic auth', () => {
|
||||
expect(h({ username: 'alice', password: 'secret' }))
|
||||
.toEqual({ Authorization: `Basic ${Buffer.from('alice:secret').toString('base64')}` });
|
||||
});
|
||||
|
||||
it('sends no auth for an unprotected topic', () => {
|
||||
expect(h({})).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores whitespace-only credentials rather than sending an empty bearer', () => {
|
||||
expect(h({ token: ' ' })).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendNtfy', () => {
|
||||
function capture() {
|
||||
const service = new AlertService();
|
||||
const calls: { url: string; body: Record<string, unknown>; headers?: Record<string, string> }[] = [];
|
||||
asNtfy(service).postJson = async (url, body, headers) => {
|
||||
calls.push({ url, body: JSON.parse(body), headers });
|
||||
};
|
||||
return { service, calls };
|
||||
}
|
||||
|
||||
it('posts to the public server by default with topic, title, priority and tag', async () => {
|
||||
const { service, calls } = capture();
|
||||
await asNtfy(service).sendNtfy({ topic: 'mt' }, 'device_offline', 'sw1 is unreachable', {});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe('https://ntfy.sh');
|
||||
expect(calls[0].body.topic).toBe('mt');
|
||||
expect(calls[0].body.priority).toBe(4);
|
||||
expect(calls[0].body.tags).toEqual(['device_offline']);
|
||||
expect(String(calls[0].body.title)).toContain('Device Offline');
|
||||
});
|
||||
|
||||
it('honours a self-hosted server and strips a trailing slash', async () => {
|
||||
const { service, calls } = capture();
|
||||
await asNtfy(service).sendNtfy(
|
||||
{ topic: 'mt', server_url: 'https://ntfy.internal.example.com/' },
|
||||
'device_online', 'back', {}
|
||||
);
|
||||
expect(calls[0].url).toBe('https://ntfy.internal.example.com');
|
||||
});
|
||||
|
||||
it('includes the device name and details in the body', async () => {
|
||||
const { service, calls } = capture();
|
||||
await asNtfy(service).sendNtfy({ topic: 'mt' }, 'log_error', 'errors seen', {
|
||||
deviceName: 'sw1', details: 'login failure',
|
||||
});
|
||||
const msg = String(calls[0].body.message);
|
||||
expect(msg).toContain('errors seen');
|
||||
expect(msg).toContain('Device: sw1');
|
||||
expect(msg).toContain('login failure');
|
||||
});
|
||||
|
||||
it('deep-links to the device when a manager URL is configured', async () => {
|
||||
const { service, calls } = capture();
|
||||
await asNtfy(service).sendNtfy(
|
||||
{ topic: 'mt', click_url: 'https://mgr.example.com/' },
|
||||
'device_offline', 'down', { deviceId: 42 }
|
||||
);
|
||||
expect(calls[0].body.click).toBe('https://mgr.example.com/devices/42');
|
||||
});
|
||||
|
||||
it('omits the click link entirely when no manager URL is set', async () => {
|
||||
const { service, calls } = capture();
|
||||
await asNtfy(service).sendNtfy({ topic: 'mt' }, 'device_offline', 'down', { deviceId: 42 });
|
||||
expect(calls[0].body.click).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a missing topic rather than posting somewhere unintended', async () => {
|
||||
const { service, calls } = capture();
|
||||
await expect(asNtfy(service).sendNtfy({}, 'device_offline', 'x', {}))
|
||||
.rejects.toThrow(/missing topic/);
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a non-http server URL', async () => {
|
||||
const { service } = capture();
|
||||
await expect(
|
||||
asNtfy(service).sendNtfy({ topic: 'mt', server_url: 'file:///etc/passwd' }, 'device_offline', 'x', {})
|
||||
).rejects.toThrow(/must be http/);
|
||||
});
|
||||
|
||||
it('rejects an unparseable server URL', async () => {
|
||||
const { service } = capture();
|
||||
await expect(
|
||||
asNtfy(service).sendNtfy({ topic: 'mt', server_url: 'not a url' }, 'device_offline', 'x', {})
|
||||
).rejects.toThrow(/invalid server_url/);
|
||||
});
|
||||
|
||||
it('passes the auth header through to the request', async () => {
|
||||
const { service, calls } = capture();
|
||||
await asNtfy(service).sendNtfy({ topic: 'mt', token: 'tk_1' }, 'device_offline', 'x', {});
|
||||
expect(calls[0].headers).toEqual({ Authorization: 'Bearer tk_1' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.20.1-beta",
|
||||
"version": "0.21.0-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1750,8 +1750,8 @@ export default function SettingsPage() {
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-400 mb-1">Type</label>
|
||||
<select className="input w-full" value={chForm.type} onChange={(e) => setChForm((f) => ({ ...f, type: e.target.value as AlertChannel['type'], config: {} }))} disabled={chModal.mode === 'edit'}>
|
||||
{(['slack', 'discord', 'telegram', 'email'] as const).map((t) => (
|
||||
<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
|
||||
{(['slack', 'discord', 'telegram', 'ntfy', 'email'] as const).map((t) => (
|
||||
<option key={t} value={t}>{t === 'ntfy' ? 'ntfy' : t.charAt(0).toUpperCase() + t.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -1784,6 +1784,40 @@ export default function SettingsPage() {
|
||||
<input className="input w-full" value={cfgStr('chat_id')} onChange={(e) => setCfg('chat_id', e.target.value)} placeholder="-100123456789" />
|
||||
</div>
|
||||
</>
|
||||
) : chForm.type === 'ntfy' ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-400 mb-1">Server URL</label>
|
||||
<input className="input w-full font-mono text-xs" value={cfgStr('server_url')} onChange={(e) => setCfg('server_url', e.target.value)} placeholder="https://ntfy.sh" />
|
||||
<p className="mt-1 text-[11px] text-gray-500 dark:text-slate-400">Leave blank for the public ntfy.sh, or point at your own instance.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-400 mb-1">Topic</label>
|
||||
<input className="input w-full font-mono text-xs" value={cfgStr('topic')} onChange={(e) => setCfg('topic', e.target.value)} placeholder="mikrotik-alerts" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-400 mb-1">Access Token</label>
|
||||
<input type="password" className="input w-full font-mono text-xs" value={cfgStr('token')} onChange={(e) => setCfg('token', e.target.value)} placeholder="tk_..." />
|
||||
<p className="mt-1 text-[11px] text-gray-500 dark:text-slate-400">
|
||||
Preferred over a username and password. Anyone who can publish to an unprotected topic can spoof your alerts.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-400 mb-1">Username</label>
|
||||
<input className="input w-full" value={cfgStr('username')} onChange={(e) => setCfg('username', e.target.value)} placeholder="(optional)" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-400 mb-1">Password</label>
|
||||
<input type="password" className="input w-full" value={cfgStr('password')} onChange={(e) => setCfg('password', e.target.value)} placeholder="••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-400 mb-1">Manager URL (optional)</label>
|
||||
<input className="input w-full font-mono text-xs" value={cfgStr('click_url')} onChange={(e) => setCfg('click_url', e.target.value)} placeholder="https://mikrotik.example.com" />
|
||||
<p className="mt-1 text-[11px] text-gray-500 dark:text-slate-400">Makes each notification tappable, opening the device it refers to.</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -825,7 +825,7 @@ export interface AlertRule {
|
||||
export interface AlertChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'email' | 'slack' | 'discord' | 'telegram';
|
||||
type: 'email' | 'slack' | 'discord' | 'telegram' | 'ntfy';
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
created_at: string;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.20.1 Beta';
|
||||
export const APP_VERSION = 'v0.21.0 Beta';
|
||||
|
||||
Reference in New Issue
Block a user