mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-02 05:38:00 +00:00
feat(settings): surface security, notifications, and app store on remote nodes (#716)
Flip Security (Trivy), Notifications (agents + history), and App Store from global-and-hidden-on-remote to node-scoped so operators can manage them when a remote node is selected in the node picker. The primary instance proxies the calls to each remote, which resolves the correct per-instance binary state, agent config, and template registry. Backend: key `agents` and `notification_history` by `node_id` with idempotent column-add migrations and a `(node_id, type)` unique index on agents, matching the Labels pattern. Thread `req.nodeId` through the /api/agents and /api/notifications routes. Internal NotificationService and ImageUpdateService writes resolve the middleware default via `NodeRegistry.getDefaultNodeId()` so monitor-emitted rows share a bucket with user-facing ones (avoids split-brain where the UI sees test notifications but not internal alerts). Frontend: split Security on remote to render only the scanner card and hide scan policies and CVE suppressions (those remain control-plane-only). Drop the misleading "Always Local" badge on Developer since retention windows govern backend jobs, not UI state. Flip the App Store registry to node-scoped. Docs: add a "What Settings apply per node" table to multi-node, clarify remote alert setup in alerts-notifications, and note Trivy's per-host install in vulnerability-scanning.
This commit is contained in:
@@ -170,12 +170,12 @@ describe('DatabaseService - cleanupOldNotifications', () => {
|
|||||||
const oldTimestamp = Date.now() - 60 * 24 * 60 * 60 * 1000; // 60 days ago
|
const oldTimestamp = Date.now() - 60 * 24 * 60 * 60 * 1000; // 60 days ago
|
||||||
const recentTimestamp = Date.now() - 1 * 24 * 60 * 60 * 1000; // 1 day ago
|
const recentTimestamp = Date.now() - 1 * 24 * 60 * 60 * 1000; // 1 day ago
|
||||||
|
|
||||||
db.addNotificationHistory({ level: 'info', message: 'old notification', timestamp: oldTimestamp });
|
db.addNotificationHistory(0, { level: 'info', message: 'old notification', timestamp: oldTimestamp });
|
||||||
db.addNotificationHistory({ level: 'info', message: 'recent notification', timestamp: recentTimestamp });
|
db.addNotificationHistory(0, { level: 'info', message: 'recent notification', timestamp: recentTimestamp });
|
||||||
|
|
||||||
db.cleanupOldNotifications(30);
|
db.cleanupOldNotifications(30);
|
||||||
|
|
||||||
const history = db.getNotificationHistory(200);
|
const history = db.getNotificationHistory(0, 200);
|
||||||
const old = history.find((n: any) => n.message === 'old notification');
|
const old = history.find((n: any) => n.message === 'old notification');
|
||||||
const recent = history.find((n: any) => n.message === 'recent notification');
|
const recent = history.find((n: any) => n.message === 'recent notification');
|
||||||
expect(old).toBeUndefined();
|
expect(old).toBeUndefined();
|
||||||
@@ -223,7 +223,7 @@ describe('DatabaseService - notification history cap', () => {
|
|||||||
it('auto-prunes to 100 entries when adding notifications', () => {
|
it('auto-prunes to 100 entries when adding notifications', () => {
|
||||||
// Insert 105 notifications
|
// Insert 105 notifications
|
||||||
for (let i = 0; i < 105; i++) {
|
for (let i = 0; i < 105; i++) {
|
||||||
db.addNotificationHistory({
|
db.addNotificationHistory(0, {
|
||||||
level: 'info',
|
level: 'info',
|
||||||
message: `cap-test-${i}`,
|
message: `cap-test-${i}`,
|
||||||
timestamp: Date.now() + i,
|
timestamp: Date.now() + i,
|
||||||
@@ -231,23 +231,23 @@ describe('DatabaseService - notification history cap', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The table should have at most 100 rows
|
// The table should have at most 100 rows
|
||||||
const all = db.getNotificationHistory(200);
|
const all = db.getNotificationHistory(0, 200);
|
||||||
expect(all.length).toBeLessThanOrEqual(100);
|
expect(all.length).toBeLessThanOrEqual(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the most recent entries after pruning', () => {
|
it('keeps the most recent entries after pruning', () => {
|
||||||
// Clear all first
|
// Clear all first
|
||||||
db.deleteAllNotifications();
|
db.deleteAllNotifications(0);
|
||||||
|
|
||||||
for (let i = 0; i < 105; i++) {
|
for (let i = 0; i < 105; i++) {
|
||||||
db.addNotificationHistory({
|
db.addNotificationHistory(0, {
|
||||||
level: 'info',
|
level: 'info',
|
||||||
message: `order-test-${i}`,
|
message: `order-test-${i}`,
|
||||||
timestamp: Date.now() + i * 10,
|
timestamp: Date.now() + i * 10,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = db.getNotificationHistory(200);
|
const all = db.getNotificationHistory(0, 200);
|
||||||
// The newest entries should survive (ordered DESC by timestamp)
|
// The newest entries should survive (ordered DESC by timestamp)
|
||||||
expect(all[0].message).toContain('order-test-');
|
expect(all[0].message).toContain('order-test-');
|
||||||
// The oldest entries (0-4) should have been pruned
|
// The oldest entries (0-4) should have been pruned
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ vi.mock('../services/NodeRegistry', () => ({
|
|||||||
NodeRegistry: {
|
NodeRegistry: {
|
||||||
getInstance: () => ({
|
getInstance: () => ({
|
||||||
getComposeDir: () => '/tmp/compose',
|
getComposeDir: () => '/tmp/compose',
|
||||||
|
getDefaultNodeId: () => 1,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -436,7 +437,7 @@ services:
|
|||||||
|
|
||||||
await (service as any).checkNode(1, 'local', fakeDb());
|
await (service as any).checkNode(1, 'local', fakeDb());
|
||||||
|
|
||||||
expect(mockAddNotificationHistory).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, expect.objectContaining({
|
||||||
level: 'error',
|
level: 'error',
|
||||||
message: expect.stringContaining('webhook timeout'),
|
message: expect.stringContaining('webhook timeout'),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ vi.mock('../services/DatabaseService', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// NodeRegistry is consulted to resolve this instance's default node id so
|
||||||
|
// internal dispatch writes land on the same key the middleware sets for user
|
||||||
|
// requests. Mock it to a fixed id so assertions are deterministic.
|
||||||
|
vi.mock('../services/NodeRegistry', () => ({
|
||||||
|
NodeRegistry: {
|
||||||
|
getInstance: () => ({
|
||||||
|
getDefaultNodeId: () => 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Spy on global fetch for webhook dispatch verification
|
// Spy on global fetch for webhook dispatch verification
|
||||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
const mockFetch = vi.fn().mockResolvedValue({ ok: true });
|
||||||
vi.stubGlobal('fetch', mockFetch);
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
@@ -210,7 +221,7 @@ describe('NotificationService - routing logic', () => {
|
|||||||
|
|
||||||
await svc.dispatchAlert('info', 'Should be logged');
|
await svc.dispatchAlert('info', 'Should be logged');
|
||||||
|
|
||||||
expect(mockAddNotificationHistory).toHaveBeenCalledWith({
|
expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, {
|
||||||
level: 'info',
|
level: 'info',
|
||||||
message: 'Should be logged',
|
message: 'Should be logged',
|
||||||
timestamp: expect.any(Number),
|
timestamp: expect.any(Number),
|
||||||
@@ -225,7 +236,7 @@ describe('NotificationService - routing logic', () => {
|
|||||||
|
|
||||||
await svc.dispatchAlert('warning', 'Restarted', 'my-app', 'my-app-web-1');
|
await svc.dispatchAlert('warning', 'Restarted', 'my-app', 'my-app-web-1');
|
||||||
|
|
||||||
expect(mockAddNotificationHistory).toHaveBeenCalledWith({
|
expect(mockAddNotificationHistory).toHaveBeenCalledWith(1, {
|
||||||
level: 'warning',
|
level: 'warning',
|
||||||
message: 'Restarted',
|
message: 'Restarted',
|
||||||
timestamp: expect.any(Number),
|
timestamp: expect.any(Number),
|
||||||
|
|||||||
+12
-6
@@ -5572,7 +5572,8 @@ function validateHttpsUrl(value: unknown): string | null {
|
|||||||
|
|
||||||
app.get('/api/agents', authMiddleware, async (req: Request, res: Response) => {
|
app.get('/api/agents', authMiddleware, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const agents = DatabaseService.getInstance().getAgents();
|
const nodeId = req.nodeId ?? 0;
|
||||||
|
const agents = DatabaseService.getInstance().getAgents(nodeId);
|
||||||
res.json(agents);
|
res.json(agents);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch agents:', error);
|
console.error('Failed to fetch agents:', error);
|
||||||
@@ -5594,7 +5595,8 @@ app.post('/api/agents', authMiddleware, async (req: Request, res: Response) => {
|
|||||||
res.status(400).json({ error: 'enabled must be a boolean' });
|
res.status(400).json({ error: 'enabled must be a boolean' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DatabaseService.getInstance().upsertAgent({ type, url, enabled });
|
const nodeId = req.nodeId ?? 0;
|
||||||
|
DatabaseService.getInstance().upsertAgent(nodeId, { type, url, enabled });
|
||||||
console.log(`[Agents] Agent ${type} updated`);
|
console.log(`[Agents] Agent ${type} updated`);
|
||||||
if (isDebugEnabled()) console.log(`[Agents:diag] Agent ${type} upsert: enabled=${enabled}`);
|
if (isDebugEnabled()) console.log(`[Agents:diag] Agent ${type} upsert: enabled=${enabled}`);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@@ -5846,7 +5848,8 @@ app.get('/api/auto-heal/policies/:id/history', authMiddleware, (req: Request, re
|
|||||||
|
|
||||||
app.get('/api/notifications', authMiddleware, async (req: Request, res: Response) => {
|
app.get('/api/notifications', authMiddleware, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const history = DatabaseService.getInstance().getNotificationHistory();
|
const nodeId = req.nodeId ?? 0;
|
||||||
|
const history = DatabaseService.getInstance().getNotificationHistory(nodeId);
|
||||||
res.json(history);
|
res.json(history);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch notifications' });
|
res.status(500).json({ error: 'Failed to fetch notifications' });
|
||||||
@@ -5855,7 +5858,8 @@ app.get('/api/notifications', authMiddleware, async (req: Request, res: Response
|
|||||||
|
|
||||||
app.post('/api/notifications/read', authMiddleware, async (req: Request, res: Response) => {
|
app.post('/api/notifications/read', authMiddleware, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
DatabaseService.getInstance().markAllNotificationsRead();
|
const nodeId = req.nodeId ?? 0;
|
||||||
|
DatabaseService.getInstance().markAllNotificationsRead(nodeId);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to mark notifications read' });
|
res.status(500).json({ error: 'Failed to mark notifications read' });
|
||||||
@@ -5866,7 +5870,8 @@ app.delete('/api/notifications/:id', authMiddleware, async (req: Request, res: R
|
|||||||
try {
|
try {
|
||||||
const id = parseInt(req.params.id as string, 10);
|
const id = parseInt(req.params.id as string, 10);
|
||||||
if (isNaN(id)) { res.status(400).json({ error: 'Invalid notification ID' }); return; }
|
if (isNaN(id)) { res.status(400).json({ error: 'Invalid notification ID' }); return; }
|
||||||
DatabaseService.getInstance().deleteNotification(id);
|
const nodeId = req.nodeId ?? 0;
|
||||||
|
DatabaseService.getInstance().deleteNotification(nodeId, id);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to delete notification' });
|
res.status(500).json({ error: 'Failed to delete notification' });
|
||||||
@@ -5875,7 +5880,8 @@ app.delete('/api/notifications/:id', authMiddleware, async (req: Request, res: R
|
|||||||
|
|
||||||
app.delete('/api/notifications', authMiddleware, async (req: Request, res: Response) => {
|
app.delete('/api/notifications', authMiddleware, async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
DatabaseService.getInstance().deleteAllNotifications();
|
const nodeId = req.nodeId ?? 0;
|
||||||
|
DatabaseService.getInstance().deleteAllNotifications(nodeId);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to clear notifications' });
|
res.status(500).json({ error: 'Failed to clear notifications' });
|
||||||
|
|||||||
@@ -449,6 +449,7 @@ export class DatabaseService {
|
|||||||
this.migrateNotificationHistoryContext();
|
this.migrateNotificationHistoryContext();
|
||||||
this.migrateScanPolicyFleetColumns();
|
this.migrateScanPolicyFleetColumns();
|
||||||
this.migrateSecretMisconfigColumns();
|
this.migrateSecretMisconfigColumns();
|
||||||
|
this.migrateAgentsAndNotificationsNodeId();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getInstance(): DatabaseService {
|
public static getInstance(): DatabaseService {
|
||||||
@@ -466,6 +467,7 @@ export class DatabaseService {
|
|||||||
this.db.exec(`
|
this.db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS agents (
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id INTEGER NOT NULL DEFAULT 0,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
enabled INTEGER DEFAULT 0
|
enabled INTEGER DEFAULT 0
|
||||||
@@ -489,6 +491,7 @@ export class DatabaseService {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS notification_history (
|
CREATE TABLE IF NOT EXISTS notification_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id INTEGER NOT NULL DEFAULT 0,
|
||||||
level TEXT NOT NULL,
|
level TEXT NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
timestamp INTEGER NOT NULL,
|
timestamp INTEGER NOT NULL,
|
||||||
@@ -1115,32 +1118,59 @@ export class DatabaseService {
|
|||||||
tryAddColumn('vulnerability_scans', 'scanners_used', "TEXT NOT NULL DEFAULT 'vuln'");
|
tryAddColumn('vulnerability_scans', 'scanners_used', "TEXT NOT NULL DEFAULT 'vuln'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private migrateAgentsAndNotificationsNodeId(): void {
|
||||||
|
const tryAddColumn = (table: string, col: string, def: string) => {
|
||||||
|
try {
|
||||||
|
this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run();
|
||||||
|
} catch {
|
||||||
|
/* column already present */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tryAddColumn('agents', 'node_id', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
tryAddColumn('notification_history', 'node_id', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
const tryIndex = (sql: string, label: string) => {
|
||||||
|
try {
|
||||||
|
this.db.prepare(sql).run();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[DatabaseService] Could not create ${label}:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tryIndex(
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_node_type ON agents(node_id, type)',
|
||||||
|
'agents(node_id, type) unique index',
|
||||||
|
);
|
||||||
|
tryIndex(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_notif_history_node_timestamp ON notification_history(node_id, timestamp DESC)',
|
||||||
|
'notification_history(node_id, timestamp) index',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Agents ---
|
// --- Agents ---
|
||||||
|
|
||||||
public getAgents(): Agent[] {
|
public getAgents(nodeId: number): Agent[] {
|
||||||
const stmt = this.db.prepare('SELECT * FROM agents');
|
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ?');
|
||||||
return stmt.all().map((row: any) => ({
|
return stmt.all(nodeId).map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
enabled: row.enabled === 1
|
enabled: row.enabled === 1
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public getEnabledAgents(): Agent[] {
|
public getEnabledAgents(nodeId: number): Agent[] {
|
||||||
const stmt = this.db.prepare('SELECT * FROM agents WHERE enabled = 1');
|
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ? AND enabled = 1');
|
||||||
return stmt.all().map((row: any) => ({
|
return stmt.all(nodeId).map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
enabled: row.enabled === 1
|
enabled: row.enabled === 1
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public upsertAgent(agent: Agent): void {
|
public upsertAgent(nodeId: number, agent: Agent): void {
|
||||||
const existing = this.db.prepare('SELECT id FROM agents WHERE type = ?').get(agent.type) as any;
|
const existing = this.db.prepare('SELECT id FROM agents WHERE node_id = ? AND type = ?').get(nodeId, agent.type) as any;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ? WHERE type = ?');
|
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ? WHERE node_id = ? AND type = ?');
|
||||||
stmt.run(agent.url, agent.enabled ? 1 : 0, agent.type);
|
stmt.run(agent.url, agent.enabled ? 1 : 0, nodeId, agent.type);
|
||||||
} else {
|
} else {
|
||||||
const stmt = this.db.prepare('INSERT INTO agents (type, url, enabled) VALUES (?, ?, ?)');
|
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled) VALUES (?, ?, ?, ?)');
|
||||||
stmt.run(agent.type, agent.url, agent.enabled ? 1 : 0);
|
stmt.run(nodeId, agent.type, agent.url, agent.enabled ? 1 : 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1370,9 +1400,9 @@ export class DatabaseService {
|
|||||||
|
|
||||||
// --- Notification History ---
|
// --- Notification History ---
|
||||||
|
|
||||||
public getNotificationHistory(limit = 50): NotificationHistory[] {
|
public getNotificationHistory(nodeId: number, limit = 50): NotificationHistory[] {
|
||||||
const stmt = this.db.prepare('SELECT * FROM notification_history ORDER BY timestamp DESC LIMIT ?');
|
const stmt = this.db.prepare('SELECT * FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT ?');
|
||||||
return stmt.all(limit).map((row: any) => ({
|
return stmt.all(nodeId, limit).map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
is_read: row.is_read === 1,
|
is_read: row.is_read === 1,
|
||||||
stack_name: row.stack_name ?? undefined,
|
stack_name: row.stack_name ?? undefined,
|
||||||
@@ -1380,11 +1410,12 @@ export class DatabaseService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
public addNotificationHistory(notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory {
|
public addNotificationHistory(nodeId: number, notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory {
|
||||||
const stmt = this.db.prepare(
|
const stmt = this.db.prepare(
|
||||||
'INSERT INTO notification_history (level, message, timestamp, is_read, stack_name, container_name) VALUES (?, ?, ?, 0, ?, ?)'
|
'INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, container_name) VALUES (?, ?, ?, ?, 0, ?, ?)'
|
||||||
);
|
);
|
||||||
const result = stmt.run(
|
const result = stmt.run(
|
||||||
|
nodeId,
|
||||||
notification.level,
|
notification.level,
|
||||||
notification.message,
|
notification.message,
|
||||||
notification.timestamp,
|
notification.timestamp,
|
||||||
@@ -1392,12 +1423,12 @@ export class DatabaseService {
|
|||||||
notification.container_name ?? null
|
notification.container_name ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
this.db.exec(`
|
this.db.prepare(`
|
||||||
DELETE FROM notification_history
|
DELETE FROM notification_history
|
||||||
WHERE id NOT IN (
|
WHERE node_id = ? AND id NOT IN (
|
||||||
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100
|
SELECT id FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT 100
|
||||||
)
|
)
|
||||||
`);
|
`).run(nodeId, nodeId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: result.lastInsertRowid as number,
|
id: result.lastInsertRowid as number,
|
||||||
@@ -1410,19 +1441,19 @@ export class DatabaseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public markAllNotificationsRead(): void {
|
public markAllNotificationsRead(nodeId: number): void {
|
||||||
const stmt = this.db.prepare('UPDATE notification_history SET is_read = 1');
|
const stmt = this.db.prepare('UPDATE notification_history SET is_read = 1 WHERE node_id = ?');
|
||||||
stmt.run();
|
stmt.run(nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public deleteNotification(id: number): void {
|
public deleteNotification(nodeId: number, id: number): void {
|
||||||
const stmt = this.db.prepare('DELETE FROM notification_history WHERE id = ?');
|
const stmt = this.db.prepare('DELETE FROM notification_history WHERE node_id = ? AND id = ?');
|
||||||
stmt.run(id);
|
stmt.run(nodeId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public deleteAllNotifications(): void {
|
public deleteAllNotifications(nodeId: number): void {
|
||||||
const stmt = this.db.prepare('DELETE FROM notification_history');
|
const stmt = this.db.prepare('DELETE FROM notification_history WHERE node_id = ?');
|
||||||
stmt.run();
|
stmt.run(nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public updateNotificationDispatchError(id: number, error: string): void {
|
public updateNotificationDispatchError(id: number, error: string): void {
|
||||||
|
|||||||
@@ -281,8 +281,10 @@ export class ImageUpdateService {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[ImageUpdateService] Failed to dispatch update notification for "${stackName}":`, e);
|
console.error(`[ImageUpdateService] Failed to dispatch update notification for "${stackName}":`, e);
|
||||||
// Direct DB write to avoid recursing through dispatchAlert if it is what failed.
|
// Direct DB write to avoid recursing through dispatchAlert if it is what failed.
|
||||||
|
// Key on the local default: the iterated `nodeId` may be a remote's id in the
|
||||||
|
// control plane's DB, and the UI never queries that row (it proxies instead).
|
||||||
try {
|
try {
|
||||||
db.addNotificationHistory({
|
db.addNotificationHistory(NodeRegistry.getInstance().getDefaultNodeId(), {
|
||||||
level: 'error',
|
level: 'error',
|
||||||
message: `[Node: ${nodeName}] Failed to notify about image updates for stack "${stackName}": ${getErrorMessage(e, String(e))}`,
|
message: `[Node: ${nodeName}] Failed to notify about image updates for stack "${stackName}": ${getErrorMessage(e, String(e))}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DatabaseService, NotificationHistory } from './DatabaseService';
|
import { DatabaseService, NotificationHistory } from './DatabaseService';
|
||||||
|
import { NodeRegistry } from './NodeRegistry';
|
||||||
import { isDebugEnabled } from '../utils/debug';
|
import { isDebugEnabled } from '../utils/debug';
|
||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
|
|
||||||
@@ -45,8 +46,11 @@ export class NotificationService {
|
|||||||
stackName?: string,
|
stackName?: string,
|
||||||
containerName?: string,
|
containerName?: string,
|
||||||
) {
|
) {
|
||||||
// 1. Log to history and get the full inserted record (with id)
|
// Internal writes use the middleware default so they share a row key
|
||||||
const notification = this.dbService.addNotificationHistory({
|
// with user-initiated requests; otherwise the UI and monitors split
|
||||||
|
// between different node_id buckets.
|
||||||
|
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||||
|
const notification = this.dbService.addNotificationHistory(localNodeId, {
|
||||||
level,
|
level,
|
||||||
message,
|
message,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -84,8 +88,8 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Fall back to global agents
|
// 4. Fall back to this instance's agents (keyed by this instance's default node id).
|
||||||
const agents = this.dbService.getEnabledAgents();
|
const agents = this.dbService.getEnabledAgents(localNodeId);
|
||||||
if (agents.length === 0) {
|
if (agents.length === 0) {
|
||||||
if (isDebugEnabled()) console.log('[Notify:diag] No routes or agents matched; skipping external dispatch');
|
if (isDebugEnabled()) console.log('[Notify:diag] No routes or agents matched; skipping external dispatch');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -135,8 +135,10 @@ The alert panel shows a blue info banner when you are configuring alerts on a re
|
|||||||
|
|
||||||
### Setup checklist for remote alerts
|
### Setup checklist for remote alerts
|
||||||
|
|
||||||
1. On the **remote** Sencho instance, go to **Settings > Notifications** and configure at least one channel
|
You can configure a remote node's notification channels directly from the control plane:
|
||||||
2. From your **primary** instance, switch to the remote node
|
|
||||||
|
1. From your **primary** instance, switch to the remote node using the node picker
|
||||||
|
2. Open **Settings > Notifications** and configure the remote's Discord, Slack, or Webhook channels. Values saved here apply only to the selected node.
|
||||||
3. Right-click a stack and select **Alerts** to create rules
|
3. Right-click a stack and select **Alerts** to create rules
|
||||||
4. The remote instance handles monitoring and notification delivery independently
|
4. The remote instance handles monitoring and notification delivery independently
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,22 @@ Click the **calendar icon** on any node row to jump directly to the Schedules vi
|
|||||||
|
|
||||||
When a node is deleted, all scheduled tasks and update status data associated with it are automatically cleaned up.
|
When a node is deleted, all scheduled tasks and update status data associated with it are automatically cleaned up.
|
||||||
|
|
||||||
|
## What Settings apply per node
|
||||||
|
|
||||||
|
When you select a remote node in the node picker, the Settings hub filters to the panels that control that specific instance. Values saved here never cross over to other nodes.
|
||||||
|
|
||||||
|
| Panel | Scope | Notes |
|
||||||
|
|-------|:-----:|-------|
|
||||||
|
| Appearance | Per browser | Density and theme preferences are stored in your browser, not on the node. |
|
||||||
|
| System Limits | Per node | Host CPU, RAM, disk, and crash-loop thresholds for the selected node. |
|
||||||
|
| Notifications | Per node | Discord, Slack, and Webhook channels fire from the node that detects the event. |
|
||||||
|
| Labels | Per node | Stack and container label palettes. |
|
||||||
|
| Security | Per node | Trivy install, update, and scanner status. Scan policies and CVE suppressions are managed on the control node and apply fleet-wide. |
|
||||||
|
| Developer | Per node | Retention windows for metrics and logs, plus Developer Mode. |
|
||||||
|
| App Store | Per node | Template registry URL for the selected node's catalog. |
|
||||||
|
|
||||||
|
Panels that manage control-plane concerns (Account, License, Users, SSO, API Tokens, Registries, Nodes, Routing, Webhooks) are hidden when a remote node is active.
|
||||||
|
|
||||||
## License enforcement across nodes
|
## License enforcement across nodes
|
||||||
|
|
||||||
When you have a paid license (Skipper or Admiral) on your primary instance, all remote nodes automatically inherit that license tier for proxied requests. You do not need to activate a license on each remote node separately.
|
When you have a paid license (Skipper or Admiral) on your primary instance, all remote nodes automatically inherit that license tier for proxied requests. You do not need to activate a license on each remote node separately.
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ Sencho integrates with [Trivy](https://trivy.dev) to scan container images for k
|
|||||||
|
|
||||||
The Trivy CLI must be available on the machine running Sencho. Trivy is not bundled with the Sencho Docker image; see [Installing Trivy](/operations/trivy-setup) for mount and installation options. Sencho checks for Trivy on startup and hides scanning UI when the binary is not available.
|
The Trivy CLI must be available on the machine running Sencho. Trivy is not bundled with the Sencho Docker image; see [Installing Trivy](/operations/trivy-setup) for mount and installation options. Sencho checks for Trivy on startup and hides scanning UI when the binary is not available.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Trivy is installed independently on each Sencho instance. When you select a remote node, **Settings > Security** shows only the scanner status for that node; install, update, or uninstall Trivy from there to manage the remote's binary. Scan policies and CVE suppressions are managed on the control node and apply fleet-wide at read time.
|
||||||
|
</Note>
|
||||||
|
|
||||||
## Tier availability
|
## Tier availability
|
||||||
|
|
||||||
| Feature | Community | Skipper | Admiral |
|
| Feature | Community | Skipper | Admiral |
|
||||||
|
|||||||
@@ -326,7 +326,6 @@ export function SettingsModal({ isOpen, onClose, initialSection, onLabelsChanged
|
|||||||
onSave={saveDeveloperSettings}
|
onSave={saveDeveloperSettings}
|
||||||
isSaving={isSavingDeveloper}
|
isSaving={isSavingDeveloper}
|
||||||
isLoading={isSettingsLoading}
|
isLoading={isSettingsLoading}
|
||||||
isRemote={isRemote}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'nodes': return <NodeManager />;
|
case 'nodes': return <NodeManager />;
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { TogglePill } from '@/components/ui/toggle-pill';
|
import { TogglePill } from '@/components/ui/toggle-pill';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
import { useLicense } from '@/context/LicenseContext';
|
import { useLicense } from '@/context/LicenseContext';
|
||||||
import { RefreshCw, Database, Info } from 'lucide-react';
|
import { RefreshCw, Database } from 'lucide-react';
|
||||||
import type { PatchableSettings } from './types';
|
import type { PatchableSettings } from './types';
|
||||||
|
|
||||||
interface DeveloperSectionProps {
|
interface DeveloperSectionProps {
|
||||||
@@ -15,7 +13,6 @@ interface DeveloperSectionProps {
|
|||||||
onSave: () => Promise<void>;
|
onSave: () => Promise<void>;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isRemote: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsSkeleton() {
|
function SettingsSkeleton() {
|
||||||
@@ -32,29 +29,11 @@ function SettingsSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading, isRemote }: DeveloperSectionProps) {
|
export function DeveloperSection({ settings, onSettingChange, onSave, isSaving, isLoading }: DeveloperSectionProps) {
|
||||||
const { isPaid, license } = useLicense();
|
const { isPaid, license } = useLicense();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isRemote && (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Badge variant="secondary" className="text-xs cursor-help">
|
|
||||||
<Info className="w-3 h-3 mr-1" />
|
|
||||||
Always Local
|
|
||||||
</Badge>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className="max-w-[220px] text-center">
|
|
||||||
These settings control this Sencho instance's UI behaviour and are never synced to remote nodes.
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? <SettingsSkeleton /> : (
|
{isLoading ? <SettingsSkeleton /> : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
|
<div className="space-y-6 bg-glass border border-glass-border p-4 rounded-lg">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { PaidGate } from '@/components/PaidGate';
|
|||||||
import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react';
|
import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info } from 'lucide-react';
|
||||||
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
|
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
|
||||||
import { useLicense } from '@/context/LicenseContext';
|
import { useLicense } from '@/context/LicenseContext';
|
||||||
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||||
import { SuppressionsPanel } from './SuppressionsPanel';
|
import { SuppressionsPanel } from './SuppressionsPanel';
|
||||||
|
|
||||||
@@ -85,6 +86,8 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
|
|
||||||
const { license } = useLicense();
|
const { license } = useLicense();
|
||||||
const isAdmiral = isPaid && license?.variant === 'admiral';
|
const isAdmiral = isPaid && license?.variant === 'admiral';
|
||||||
|
const { activeNode } = useNodes();
|
||||||
|
const isRemote = activeNode?.type === 'remote';
|
||||||
const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus();
|
const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus();
|
||||||
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update'>(null);
|
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update'>(null);
|
||||||
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
const [uninstallConfirm, setUninstallConfirm] = useState(false);
|
||||||
@@ -100,7 +103,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
setTrivyBusy(op);
|
setTrivyBusy(op);
|
||||||
const toastId = toast.loading(loading);
|
const toastId = toast.loading(loading);
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(path, { method, localOnly: true });
|
const res = await apiFetch(path, { method });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err?.error || `Trivy ${op} failed`);
|
throw new Error(err?.error || `Trivy ${op} failed`);
|
||||||
@@ -127,7 +130,6 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
try {
|
try {
|
||||||
const res = await apiFetch('/security/trivy-auto-update', {
|
const res = await apiFetch('/security/trivy-auto-update', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
localOnly: true,
|
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -158,11 +160,17 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPaid) fetchPolicies();
|
if (!isPaid) { setLoading(false); return; }
|
||||||
else setLoading(false);
|
if (isRemote) { setPolicies([]); setLoading(false); return; }
|
||||||
}, [isPaid]);
|
fetchPolicies();
|
||||||
|
}, [isPaid, isRemote]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
void refreshTrivy();
|
||||||
|
}, [activeNode?.id, refreshTrivy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRemote) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -177,7 +185,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, [isRemote]);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@@ -267,7 +275,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{!isReplica && (
|
{!isRemote && !isReplica && (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button size="sm" onClick={openCreate}>
|
<Button size="sm" onClick={openCreate}>
|
||||||
<Plus className="w-4 h-4 mr-1.5" />
|
<Plus className="w-4 h-4 mr-1.5" />
|
||||||
@@ -276,7 +284,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isReplica && (
|
{!isRemote && isReplica && (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
@@ -367,14 +375,30 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{isRemote && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex items-start gap-2 rounded-lg border border-card-border bg-muted/30 px-4 py-3"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" strokeWidth={1.5} aria-hidden="true" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">Scanner is per-node</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Trivy is installed independently on each Sencho instance. Scan policies and CVE suppressions are managed on the control node.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRemote && loading && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-20 w-full rounded-lg" />
|
<Skeleton className="h-20 w-full rounded-lg" />
|
||||||
<Skeleton className="h-20 w-full rounded-lg" />
|
<Skeleton className="h-20 w-full rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && policies.length === 0 && (
|
{!isRemote && !loading && policies.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<ShieldCheck className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
<ShieldCheck className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">No scan policies configured.</p>
|
<p className="text-sm text-muted-foreground">No scan policies configured.</p>
|
||||||
@@ -384,7 +408,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading &&
|
{!isRemote && !loading &&
|
||||||
policies.map((policy) => (
|
policies.map((policy) => (
|
||||||
<div key={policy.id} className="border border-glass-border rounded-lg p-4 space-y-3">
|
<div key={policy.id} className="border border-glass-border rounded-lg p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -436,7 +460,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<SuppressionsPanel isReplica={isReplica} />
|
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
|||||||
description: 'In-app toasts and browser push for stack, container, and system events.',
|
description: 'In-app toasts and browser push for stack, container, and system events.',
|
||||||
keywords: ['toasts', 'push', 'events', 'alerts', 'inbox'],
|
keywords: ['toasts', 'push', 'events', 'alerts', 'inbox'],
|
||||||
tier: null,
|
tier: null,
|
||||||
scope: 'global',
|
scope: 'node',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'notification-routing',
|
id: 'notification-routing',
|
||||||
@@ -170,9 +170,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
|||||||
description: 'Image scanning, suppressions, and posture defaults.',
|
description: 'Image scanning, suppressions, and posture defaults.',
|
||||||
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'],
|
keywords: ['scan', 'cve', 'trivy', 'suppressions', 'hardening'],
|
||||||
tier: 'skipper',
|
tier: 'skipper',
|
||||||
scope: 'global',
|
scope: 'node',
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
hiddenOnRemote: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'developer',
|
id: 'developer',
|
||||||
@@ -190,8 +189,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
|||||||
description: 'Template registry URL and featured-catalog source.',
|
description: 'Template registry URL and featured-catalog source.',
|
||||||
keywords: ['templates', 'registry', 'catalog', 'featured'],
|
keywords: ['templates', 'registry', 'catalog', 'featured'],
|
||||||
tier: null,
|
tier: null,
|
||||||
scope: 'global',
|
scope: 'node',
|
||||||
hiddenOnRemote: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'support',
|
id: 'support',
|
||||||
|
|||||||
Reference in New Issue
Block a user