feat: Morning NOC Summary adaptive card for Teams

- Add MorningSummaryService with Zabbix aggregation and adaptive card builder
- Add webhook delivery system with Teams incoming webhooks
- Add admin UI at /admin/morning-summary for webhook/config management
- Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history
- Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM)
- Add outages_only filter (Unavailable triggers only)
- Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts
- Fix resolved events: event.get value:1 scoped to window with r_eventid filter
- Remove emojis from fact rows and section headers in card
- Remove Open Zabbix button (duplicate of View Problems)
- Add migrations: morning_summary_config + morning_summaries tables
- Add outages_only column to morning_summary_config
This commit is contained in:
lorentz 2026-03-11 09:34:51 -04:00
parent 19605f82aa
commit c518eefdb2
61 changed files with 11236 additions and 237 deletions

View file

@ -6,6 +6,8 @@ import {
ZabbixHostCreateParams,
ZabbixHostUpdateParams,
ZabbixRpcResponse,
ZabbixProblem,
ZabbixEvent,
} from '@/lib/types/zabbix';
export type { ZabbixHostTag } from '@/lib/types/zabbix';
@ -116,6 +118,119 @@ export class ZabbixClient {
return null;
}
/**
* Fetch all hosts with full detail: interfaces, tags, macros, groups.
*/
async getHosts(): Promise<ZabbixHost[]> {
return this.rpc<ZabbixHost[]>('host.get', {
output: 'extend',
selectInterfaces: 'extend',
selectTags: 'extend',
selectMacros: 'extend',
selectGroups: 'extend',
selectParentTemplates: ['templateid', 'host', 'name'],
});
}
/**
* Delete one or more hosts by their hostids.
*/
async deleteHosts(hostids: string[]): Promise<void> {
await this.rpc<{ hostids: string[] }>('host.delete', hostids);
}
/**
* Get open problems with hosts inline.
* Only returns problems whose trigger is linked to at least one enabled host.
* severities: 2=Warning,3=Average,4=High,5=Disaster
*/
async getOpenProblems(minSeverity = 2): Promise<ZabbixProblem[]> {
return this.rpc<ZabbixProblem[]>('problem.get', {
output: 'extend',
selectAcknowledges: 'extend',
selectSuppressionData: 'extend',
severities: [2, 3, 4, 5].filter(s => s >= minSeverity),
recent: true,
sortfield: 'eventid',
sortorder: 'DESC',
});
}
/**
* Get problems that were resolved within the given window.
* Uses problem.get with time_from/time_till (filters on problem clock) and
* r_eventid IS SET (meaning the problem has a recovery event = resolved).
* Returns problems only r_clock is the resolution timestamp.
*/
async getResolvedEvents(from: Date, to: Date): Promise<ZabbixEvent[]> {
const fromSec = Math.floor(from.getTime() / 1000);
const toSec = Math.floor(to.getTime() / 1000);
// event.get with value:1 returns PROBLEM trigger events.
// time_from/time_till filter on event creation (problem start) time.
// We then keep only those that have an r_clock (= resolved) within the window.
const events = await this.rpc<ZabbixEvent[]>('event.get', {
output: 'extend',
source: 0,
object: 0,
value: 1,
time_from: fromSec,
time_till: toSec,
severities: [2, 3, 4, 5],
sortfield: 'eventid',
sortorder: 'DESC',
limit: 500,
});
return events.filter(e => e.r_eventid && e.r_eventid !== '0');
}
/**
* Fetch group names for a specific list of hostids.
* Returns a Map<hostid, groupName[]> for client name resolution.
*/
async getHostGroupMap(hostids: string[]): Promise<Map<string, string[]>> {
if (hostids.length === 0) return new Map();
const hosts = await this.rpc<Array<{
hostid: string;
groups: Array<{ groupid: string; name: string }>;
}>>('host.get', {
output: ['hostid'],
hostids,
selectGroups: ['groupid', 'name'],
});
const map = new Map<string, string[]>();
for (const h of hosts) {
map.set(h.hostid, (h.groups ?? []).map((g: { name: string }) => g.name));
}
return map;
}
/**
* For a list of triggerids, return a map of triggerid { hostid, hostName }
* filtered to only enabled hosts (status '0').
* Triggerids with no enabled host are omitted from the map.
*/
async getTriggerEnabledHosts(triggerids: string[]): Promise<Map<string, { hostid: string; hostName: string }>> {
if (triggerids.length === 0) return new Map();
const triggers = await this.rpc<Array<{
triggerid: string;
hosts: Array<{ hostid: string; name: string; status: string }>;
}>>('trigger.get', {
output: ['triggerid'],
triggerids,
selectHosts: ['hostid', 'name', 'status'],
});
const map = new Map<string, { hostid: string; hostName: string }>();
for (const t of triggers) {
const enabled = (t.hosts ?? []).find(h => h.status === '0');
if (enabled) map.set(t.triggerid, { hostid: enabled.hostid, hostName: enabled.name });
}
return map;
}
/**
* Create or update a Zabbix host. Idempotent looks up by host name first.
* Returns the hostid and whether the host was created or updated.