diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/KscRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/KscRestService.java index d57b91a4e67f..2b2571f89587 100644 --- a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/KscRestService.java +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/KscRestService.java @@ -28,6 +28,7 @@ import java.util.Map; import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.PUT; @@ -75,7 +76,10 @@ public class KscRestService extends OnmsRestService { @Produces({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON, MediaType.APPLICATION_ATOM_XML}) @Transactional public KscReportCollection getReports() throws ParseException { - final KscReportCollection reports = new KscReportCollection(m_kscReportService.getReportMap(), true); + // Non-terse: include each report's graphs so the list can show a graph + // count and callers can edit a report from the list without a broken, + // graph-less copy (a terse list once caused edits to overwrite graphs). + final KscReportCollection reports = new KscReportCollection(m_kscReportService.getReportMap(), false); reports.setTotalCount(reports.size()); return reports; } @@ -165,33 +169,21 @@ public Response addGraph(@PathParam("kscReportId") final Integer kscReportId, @Q } @POST - @Consumes(MediaType.APPLICATION_XML) + @Consumes({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON}) public Response addKscReport(@Context final UriInfo uriInfo, final KscReport kscReport) { writeLock(); try { LOG.debug("addKscReport: Adding KSC Report {}", kscReport); - Report report = m_kscReportFactory.getReportByIndex(kscReport.getId()); - if (report != null) { + // A supplied id must not collide with an existing report; a null id + // means "assign the next available one" (addReport does that on save). + if (kscReport.getId() != null && m_kscReportFactory.getReportByIndex(kscReport.getId()) != null) { throw getException(Status.CONFLICT, "Invalid request: Existing KSC report found with ID: {}.", Integer.toString(kscReport.getId())); } - report = new Report(); - report.setId(kscReport.getId()); - report.setTitle(kscReport.getLabel()); - if (kscReport.getShowGraphtypeButton() != null) { - report.setShowGraphtypeButton(kscReport.getShowGraphtypeButton()); - } - if (kscReport.getShowTimespanButton() != null) { - report.setShowTimespanButton(kscReport.getShowTimespanButton()); - } - if (kscReport.getGraphsPerLine() != null) { - report.setGraphsPerLine(kscReport.getGraphsPerLine()); - } - if (kscReport.hasGraphs()) { - for (KscGraph kscGraph : kscReport.getGraphs()) { - final Graph graph = kscGraph.buildGraph(); - report.addGraph(graph); - } + final Report report = new Report(); + if (kscReport.getId() != null) { + report.setId(kscReport.getId()); } + applyReportFields(kscReport, report); m_kscReportFactory.addReport(report); try { @@ -199,12 +191,75 @@ public Response addKscReport(@Context final UriInfo uriInfo, final KscReport ksc } catch (final Exception e) { throw getException(Status.BAD_REQUEST, e.getMessage()); } - return Response.created(getRedirectUri(uriInfo, kscReport.getId())).build(); + return Response.created(getRedirectUri(uriInfo, report.getId())).build(); } finally { writeUnlock(); } } + @POST + @Path("{reportId}") + @Consumes({MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON}) + @Transactional + public Response updateKscReport(@PathParam("reportId") final Integer reportId, final KscReport kscReport) { + writeLock(); + try { + if (m_kscReportFactory.getReportByIndex(reportId) == null) { + throw getException(Status.NOT_FOUND, "No such report id {}.", Integer.toString(reportId)); + } + final Report report = new Report(); + report.setId(reportId); + applyReportFields(kscReport, report); + m_kscReportFactory.setReport(reportId, report); + try { + m_kscReportFactory.saveCurrent(); + } catch (final Exception e) { + throw getException(Status.INTERNAL_SERVER_ERROR, "Cannot save report with Id {} : {} ", reportId.toString(), e.getMessage()); + } + return Response.noContent().build(); + } finally { + writeUnlock(); + } + } + + @DELETE + @Path("{reportId}") + @Transactional + public Response deleteKscReport(@PathParam("reportId") final Integer reportId) { + writeLock(); + try { + if (m_kscReportFactory.getReportByIndex(reportId) == null) { + throw getException(Status.NOT_FOUND, "No such report id {}.", Integer.toString(reportId)); + } + try { + m_kscReportFactory.deleteReportAndSave(reportId); + } catch (final Exception e) { + throw getException(Status.INTERNAL_SERVER_ERROR, "Cannot delete report with Id {} : {} ", reportId.toString(), e.getMessage()); + } + return Response.noContent().build(); + } finally { + writeUnlock(); + } + } + + private static void applyReportFields(final KscReport source, final Report target) { + target.setTitle(source.getLabel()); + if (source.getShowGraphtypeButton() != null) { + target.setShowGraphtypeButton(source.getShowGraphtypeButton()); + } + if (source.getShowTimespanButton() != null) { + target.setShowTimespanButton(source.getShowTimespanButton()); + } + if (source.getGraphsPerLine() != null) { + target.setGraphsPerLine(source.getGraphsPerLine()); + } + if (source.hasGraphs()) { + for (final KscGraph kscGraph : source.getGraphs()) { + target.addGraph(kscGraph.buildGraph()); + } + } + } + @XmlRootElement(name = "kscReports") public static final class KscReportCollection extends JaxbListWrapper { diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json index 497c84fdfcc4..949b4d3ef9e8 100644 --- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json +++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json @@ -72,6 +72,13 @@ { "id": "kscReports", "name": "Graph Collections", + "url": "ui/index.html#/graph-collections", + "locationMatch": "graph-collections", + "roles": null + }, + { + "id": "kscReportsLegacy", + "name": "Graph Collections (Legacy)", "url": "KSC/index.jsp", "locationMatch": "ksc", "roles": null diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/KscRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/KscRestServiceIT.java index 39b84b3f7d8b..dbb2efa9f1a9 100644 --- a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/KscRestServiceIT.java +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/KscRestServiceIT.java @@ -141,6 +141,66 @@ public void testAddNewGraph() throws Exception { assertTrue(xml, xml.contains("title=\"foo2\"")); } + @Test + public void testListIncludesGraphs() throws Exception { + // The list is non-terse: each report carries its graphs, not just id/label. + final String xml = sendRequest(GET, "/ksc", 200); + assertTrue(xml, xml.contains("graphtype=\"ssh\"")); + } + + @Test + public void testCreateAssignsId() throws Exception { + // No id attribute -> the server assigns the next available id. + final String body = "" + + "" + + ""; + sendPost("/ksc", body, 201); + + // The config file uses the JAXB Report's "title" attribute, not the DTO's "label". + final String xml = slurp(m_configFile); + assertTrue(xml, xml.contains("title=\"AutoId\"")); + } + + @Test + public void testUpdateReportReplacesContents() throws Exception { + final String create = "" + + "" + + ""; + sendPost("/ksc", create, 201, "/ksc/7"); + + final String update = "" + + "" + + ""; + sendPost("/ksc/7", update, 204); + + final String xml = slurp(m_configFile); + assertTrue(xml, xml.contains("title=\"Edited\"")); + assertTrue(xml, xml.contains("title=\"g2\"")); + // Full replace: the previous graph is gone. + assertTrue(xml, !xml.contains("title=\"g1\"")); + } + + @Test + public void testUpdateMissingReport() throws Exception { + sendPost("/ksc/99", "", 404); + } + + @Test + public void testDeleteReport() throws Exception { + final String create = "" + + "" + + ""; + sendPost("/ksc", create, 201, "/ksc/8"); + sendRequest(GET, "/ksc/8", 200); + sendRequest(DELETE, "/ksc/8", 204); + sendRequest(GET, "/ksc/8", 404); + } + + @Test + public void testDeleteMissingReport() throws Exception { + sendRequest(DELETE, "/ksc/99", 404); + } + private static String slurp(final File file) throws Exception { Reader fileReader = null; BufferedReader reader = null; diff --git a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java index fa1b1cc38811..e419636f5a9f 100644 --- a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java +++ b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java @@ -78,7 +78,8 @@ public void testMenuEntries() throws Exception { wait.until(ExpectedConditions.presenceOfElementLocated(By.xpath("//div[@class='card-header']/span"))); clickMenuItem("Dashboards", "Graph Collections"); - wait.until(ExpectedConditions.presenceOfElementLocated(By.xpath("//div[@class='card-header']/span[text()='Customized Reports']"))); + // now the Vue page (ui/index.html) + wait.until(ExpectedConditions.presenceOfElementLocated(By.xpath("//div[@id='app']//div[@class='card-title' and text()='Graph Collections']"))); clickMenuItem("Dashboards", "Surveillance Dashboard"); driver.switchTo().frame(findElementByXpath("/html/body/div//iframe")); diff --git a/ui/src/components/Ksc/GraphCollectionEditorDialog.vue b/ui/src/components/Ksc/GraphCollectionEditorDialog.vue new file mode 100644 index 000000000000..a90a76c69b28 --- /dev/null +++ b/ui/src/components/Ksc/GraphCollectionEditorDialog.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/ui/src/components/Ksc/KscGraphEditorDialog.vue b/ui/src/components/Ksc/KscGraphEditorDialog.vue new file mode 100644 index 000000000000..5f58953961b1 --- /dev/null +++ b/ui/src/components/Ksc/KscGraphEditorDialog.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/ui/src/components/Ksc/utils/kscResource.ts b/ui/src/components/Ksc/utils/kscResource.ts new file mode 100644 index 000000000000..a537fbf77f2a --- /dev/null +++ b/ui/src/components/Ksc/utils/kscResource.ts @@ -0,0 +1,44 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +// Legacy KSC configs may store the resource id percent-encoded, occasionally +// twice (NMS-10309). Decode while the value still looks encoded, matching +// DefaultKscReportService.getResourceIdForGraph, and guard malformed sequences +// so a bad value falls back to the raw string rather than throwing. +export const decodeResourceId = (raw?: string | null): string => { + if (!raw) { + return '' + } + let value = raw + for (let i = 0; i < 2 && /%[0-9a-fA-F]{2}/.test(value); i++) { + try { + const decoded = decodeURIComponent(value) + if (decoded === value) { + break + } + value = decoded + } catch { + break + } + } + return value +} diff --git a/ui/src/components/Ksc/utils/kscTimespan.ts b/ui/src/components/Ksc/utils/kscTimespan.ts new file mode 100644 index 000000000000..3bef10317822 --- /dev/null +++ b/ui/src/components/Ksc/utils/kscTimespan.ts @@ -0,0 +1,187 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +import { add, getMonth, getUnixTime, set, setDate, setMonth, startOfDay, startOfWeek, sub } from 'date-fns' +import type { Duration } from 'date-fns' +import { StartEndTime } from '@/types' + +// The symbolic timespans a KSC graph may carry, in the same order the legacy +// editor presented them. Kept in lockstep with +// KSC_PerformanceReportFactory.TIMESPAN_OPTIONS on the server. +export const KSC_TIMESPAN_OPTIONS: string[] = [ + '1_hour', + '2_hour', + '4_hour', + '6_hour', + '8_hour', + '12_hour', + '1_day', + '2_day', + '7_day', + '1_month', + '3_month', + '6_month', + '1_year', + 'Today', + 'Yesterday', + 'Yesterday 9am-5pm', + 'Yesterday 5pm-10pm', + 'This Week', + 'Last Week', + 'This Month', + 'Last Month', + 'This Quarter', + 'Last Quarter', + 'This Year', + 'Last Year' +] + +// Relative spans that just subtract a fixed amount from "now"; the month/year +// values mirror the day-count arithmetic in getBeginEndTime (30/90/183/365). +const RELATIVE: Record = { + '1_hour': { hours: 1 }, + '2_hour': { hours: 2 }, + '4_hour': { hours: 4 }, + '6_hour': { hours: 6 }, + '8_hour': { hours: 8 }, + '12_hour': { hours: 12 }, + '1_day': { days: 1 }, + '2_day': { days: 2 }, + '7_day': { days: 7 }, + '1_month': { days: 30 }, + '3_month': { days: 90 }, + '6_month': { days: 183 }, + '1_year': { days: 365 } +} + +// Match the label granularity to the span so the x-axis stays legible; mirrors +// what TimeControls picks for the equivalent ranges on the resource graphs. +const pickFormat = (startSec: number, endSec: number): string => { + const spanHours = (endSec - startSec) / 3600 + if (spanHours <= 1) { + return 'minutes' + } + if (spanHours <= 48) { + return 'hours' + } + if (spanHours <= 24 * 60) { + return 'days' + } + if (spanHours <= 24 * 365 * 2) { + return 'months' + } + return 'years' +} + +// The first month of the quarter containing `month` (0-based), and the month +// that starts the following quarter (12 → wrap into next year). +const quarterBounds = (month: number): { begin: number, end: number } => { + const begin = Math.floor(month / 3) * 3 + return { begin, end: begin + 3 } +} + +// Resolve a KSC symbolic timespan (e.g. '7_day', 'Last Quarter') to concrete +// start/end times plus a label format, mirroring +// KSC_PerformanceReportFactory.getBeginEndTime. `now` is injectable for tests. +export const kscTimespanToStartEndTime = (timespan: string, now: Date = new Date()): StartEndTime => { + let begin: Date + let end: Date + + const relative = RELATIVE[timespan] + if (relative) { + begin = sub(now, relative) + end = now + } else { + // Calendar-aligned spans: zero the time-of-day on both ends first. + const begin0 = startOfDay(now) + const end0 = startOfDay(now) + + switch (timespan) { + case 'Today': + begin = begin0 + end = add(end0, { days: 1 }) + break + case 'Yesterday': + begin = sub(begin0, { days: 1 }) + end = end0 + break + case 'Yesterday 9am-5pm': + begin = set(sub(begin0, { days: 1 }), { hours: 9 }) + end = set(sub(end0, { days: 1 }), { hours: 17 }) + break + case 'Yesterday 5pm-10pm': + begin = set(sub(begin0, { days: 1 }), { hours: 17 }) + end = set(sub(end0, { days: 1 }), { hours: 22 }) + break + case 'This Week': + case 'Last Week': { + begin = startOfWeek(begin0) + end = set(add(startOfWeek(end0), { days: 6 }), { hours: 23, minutes: 59 }) + if (timespan === 'Last Week') { + begin = sub(begin, { days: 7 }) + end = sub(end, { days: 7 }) + } + break + } + case 'This Month': + begin = setDate(begin0, 1) + end = setDate(add(end0, { months: 1 }), 1) + break + case 'Last Month': + begin = setDate(sub(begin0, { months: 1 }), 1) + end = setDate(end0, 1) + break + case 'This Quarter': + case 'Last Quarter': { + const b1 = setDate(begin0, 1) + const e1 = setDate(end0, 1) + const { begin: bMonth, end: eMonth } = quarterBounds(getMonth(b1)) + begin = setMonth(b1, bMonth) + // eMonth is 3..12; setMonth rolls 12 into January of the next year, + // matching the Java case that bumps the year for Q4. + end = setMonth(e1, eMonth) + if (timespan === 'Last Quarter') { + begin = sub(begin, { months: 3 }) + end = sub(end, { months: 3 }) + } + break + } + case 'This Year': + begin = setDate(setMonth(begin0, 0), 1) + end = add(setDate(setMonth(end0, 0), 1), { years: 1 }) + break + case 'Last Year': + begin = sub(setDate(setMonth(begin0, 0), 1), { years: 1 }) + end = setDate(setMonth(end0, 0), 1) + break + default: + // Unknown/invalid timespan: fall back to the last day rather than throw, + // so a single bad graph can't break a whole report view. + begin = sub(now, { days: 1 }) + end = now + } + } + + const startTime = getUnixTime(begin) + const endTime = getUnixTime(end) + return { startTime, endTime, format: pickFormat(startTime, endTime) } +} diff --git a/ui/src/containers/GraphCollectionView.vue b/ui/src/containers/GraphCollectionView.vue new file mode 100644 index 000000000000..cb14c64351e3 --- /dev/null +++ b/ui/src/containers/GraphCollectionView.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/ui/src/containers/GraphCollections.vue b/ui/src/containers/GraphCollections.vue new file mode 100644 index 000000000000..c6bed8d293c9 --- /dev/null +++ b/ui/src/containers/GraphCollections.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/ui/src/main/router/index.ts b/ui/src/main/router/index.ts index 2ee25c493860..ba28fab12ed8 100644 --- a/ui/src/main/router/index.ts +++ b/ui/src/main/router/index.ts @@ -242,6 +242,18 @@ const router = createRouter({ } ] }, + { + path: '/graph-collections', + name: 'GraphCollections', + component: () => import('@/containers/GraphCollections.vue') + }, + { + // Constrain :id to a non-negative integer (KSC report ids start at 0). + path: '/graph-collections/:id(\\d+)', + name: 'GraphCollectionView', + props: true, + component: () => import('@/containers/GraphCollectionView.vue') + }, { path: '/open-api', name: 'OpenAPI', diff --git a/ui/src/services/index.ts b/ui/src/services/index.ts index 7a60af9a0cc2..a2b66d2315f2 100644 --- a/ui/src/services/index.ts +++ b/ui/src/services/index.ts @@ -67,6 +67,14 @@ import { getWhoAmI } from './whoAmIService' import { getInfo } from './infoService' import { getOpenApiV1, getOpenApi } from './helpService' import { getResources, getResourceForNode, getResourceById } from './resourceService' +import { + getKscReports, + getKscReport, + createKscReport, + updateKscReport, + deleteKscReport, + reloadKscConfig +} from './kscService' import { getPlugins } from './pluginService' import { getUsageStatistics, @@ -128,6 +136,12 @@ export default { getResourceForNode, getResourceById, getGraphDefinitionsByResourceId, + getKscReports, + getKscReport, + createKscReport, + updateKscReport, + deleteKscReport, + reloadKscConfig, getPlugins, getServiceTypes, getDeviceConfigBackups, diff --git a/ui/src/services/kscService.ts b/ui/src/services/kscService.ts new file mode 100644 index 000000000000..e551e9bf1037 --- /dev/null +++ b/ui/src/services/kscService.ts @@ -0,0 +1,71 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +import { rest } from './axiosInstances' +import { KscReport } from '@/types/ksc' + +const endpoint = '/ksc' + +// The list read lets errors propagate so the store can tell a real failure +// (backend down, config unreadable) apart from an empty configuration instead +// of rendering both as "no reports". Single-report read and mutations are +// handled by their callers. + +const getKscReports = async (): Promise => { + const resp = await rest.get(endpoint) + if (resp.status === 204) { + return [] + } + return resp.data?.kscReport ?? [] +} + +const getKscReport = async (id: number): Promise => { + try { + const resp = await rest.get(`${endpoint}/${id}`) + return resp.data + } catch (_err) { + return null + } +} + +// Returns the id the server assigned to the new report (parsed from the +// Location header), or null if it could not be determined. +const createKscReport = async (report: KscReport): Promise => { + const resp = await rest.post(endpoint, report) + const location = (resp.headers?.location ?? '') as string + const id = Number(location.split('/').filter(Boolean).pop()) + return Number.isFinite(id) ? id : null +} + +const updateKscReport = async (id: number, report: KscReport): Promise => { + await rest.post(`${endpoint}/${id}`, report) +} + +const deleteKscReport = async (id: number): Promise => { + await rest.delete(`${endpoint}/${id}`) +} + +const reloadKscConfig = async (): Promise => { + await rest.put(`${endpoint}/reloadConfig`) +} + +export { getKscReports, getKscReport, createKscReport, updateKscReport, deleteKscReport, reloadKscConfig } diff --git a/ui/src/stores/kscStore.ts b/ui/src/stores/kscStore.ts new file mode 100644 index 000000000000..8cf5b577b450 --- /dev/null +++ b/ui/src/stores/kscStore.ts @@ -0,0 +1,106 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +import API from '@/services' +import useSnackbar from '@/composables/useSnackbar' +import { KscReport } from '@/types/ksc' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +// OnmsRestService errors come back as a plain-text body; fall back to the axios +// message so the user sees something actionable rather than a bare "failed". +const errorMessage = (err: unknown, fallback: string): string => { + const e = err as { response?: { data?: unknown }, message?: string } + const data = e?.response?.data + if (typeof data === 'string' && data.trim()) { + return data.trim() + } + return e?.message || fallback +} + +export const useKscStore = defineStore('kscStore', () => { + const reports = ref([]) + const loading = ref(false) + const { showSnackBar } = useSnackbar() + + const load = async () => { + loading.value = true + try { + reports.value = await API.getKscReports() + } catch (err) { + // Surface the failure rather than leaving an empty list that reads as + // "no reports configured"; keep whatever was already loaded. + showSnackBar({ msg: errorMessage(err, 'Could not load graph collections.'), error: true }) + } finally { + loading.value = false + } + } + + const getReport = async (id: number): Promise => { + return await API.getKscReport(id) + } + + // Create when id is absent, otherwise replace the report in place. Returns + // whether the save succeeded so the dialog can stay open on failure. + const saveReport = async (report: KscReport): Promise => { + try { + if (report.id === null || report.id === undefined) { + await API.createKscReport(report) + } else { + await API.updateKscReport(report.id, report) + } + showSnackBar({ msg: `Report '${report.label}' saved.` }) + await load() + return true + } catch (err) { + showSnackBar({ msg: errorMessage(err, 'Could not save the report.'), error: true }) + return false + } + } + + const removeReport = async (report: KscReport): Promise => { + if (report.id === null || report.id === undefined) { + return false + } + try { + await API.deleteKscReport(report.id) + showSnackBar({ msg: `Report '${report.label}' deleted.` }) + await load() + return true + } catch (err) { + showSnackBar({ msg: errorMessage(err, 'Could not delete the report.'), error: true }) + return false + } + } + + const reloadConfig = async (): Promise => { + try { + await API.reloadKscConfig() + showSnackBar({ msg: 'KSC configuration reloaded from disk.' }) + await load() + } catch (err) { + showSnackBar({ msg: errorMessage(err, 'Could not reload the configuration.'), error: true }) + } + } + + return { reports, loading, load, getReport, saveReport, removeReport, reloadConfig } +}) diff --git a/ui/src/types/ksc.ts b/ui/src/types/ksc.ts new file mode 100644 index 000000000000..d4298584c6fb --- /dev/null +++ b/ui/src/types/ksc.ts @@ -0,0 +1,47 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +// One graph within a KSC report. Field names mirror the rest/ksc JSON exactly +// (KscRestService.KscGraph) so a fetched report round-trips back unchanged on save. +export interface KscGraph { + title: string + timespan: string + graphtype: string + resourceId?: string | null + nodeId?: string | null + nodeSource?: string | null + domain?: string | null + interfaceId?: string | null + extlink?: string | null +} + +// A KSC ("Graph Collections") report. `id` is null only for a not-yet-created +// report; the server assigns it. The list endpoint returns the same shape with +// an empty kscGraph array. +export interface KscReport { + id: number | null + label: string + show_timespan_button?: boolean | null + show_graphtype_button?: boolean | null + graphs_per_line?: number | null + kscGraph: KscGraph[] +} diff --git a/ui/tests/components/Ksc/kscResource.test.ts b/ui/tests/components/Ksc/kscResource.test.ts new file mode 100644 index 000000000000..4faccf79735f --- /dev/null +++ b/ui/tests/components/Ksc/kscResource.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest' +import { decodeResourceId } from '@/components/Ksc/utils/kscResource' + +describe('decodeResourceId', () => { + it('returns an empty string for null/undefined/empty', () => { + expect(decodeResourceId(undefined)).toBe('') + expect(decodeResourceId(null)).toBe('') + expect(decodeResourceId('')).toBe('') + }) + + it('leaves an unencoded resource id untouched', () => { + const id = 'node[1].interfaceSnmp[en0]' + expect(decodeResourceId(id)).toBe(id) + }) + + it('decodes a single-encoded resource id', () => { + expect(decodeResourceId('node%5B1%5D.interfaceSnmp%5Ben0%5D')).toBe('node[1].interfaceSnmp[en0]') + }) + + it('decodes a double-encoded resource id (NMS-10309)', () => { + expect(decodeResourceId('node%255B1%255D')).toBe('node[1]') + }) + + it('falls back to the raw value on a malformed sequence rather than throwing', () => { + // %C3%28 is an invalid UTF-8 sequence; decodeURIComponent throws. + expect(decodeResourceId('%C3%28')).toBe('%C3%28') + expect(decodeResourceId('50%')).toBe('50%') + }) +}) diff --git a/ui/tests/components/Ksc/kscTimespan.test.ts b/ui/tests/components/Ksc/kscTimespan.test.ts new file mode 100644 index 000000000000..14c51b112a39 --- /dev/null +++ b/ui/tests/components/Ksc/kscTimespan.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest' +import { add, getUnixTime, startOfDay, startOfWeek, sub } from 'date-fns' +import { kscTimespanToStartEndTime, KSC_TIMESPAN_OPTIONS } from '@/components/Ksc/utils/kscTimespan' + +// Wednesday, 15 July 2026, 13:30 local time — a fixed reference so every +// calendar-relative case is deterministic. +const NOW = new Date(2026, 6, 15, 13, 30, 0) + +describe('kscTimespanToStartEndTime', () => { + it('exposes all 25 legacy timespan options in order', () => { + expect(KSC_TIMESPAN_OPTIONS).toHaveLength(25) + expect(KSC_TIMESPAN_OPTIONS[0]).toBe('1_hour') + expect(KSC_TIMESPAN_OPTIONS[KSC_TIMESPAN_OPTIONS.length - 1]).toBe('Last Year') + }) + + it('maps relative spans to now minus the offset', () => { + const r = kscTimespanToStartEndTime('7_day', NOW) + expect(r.endTime).toBe(getUnixTime(NOW)) + expect(r.startTime).toBe(getUnixTime(sub(NOW, { days: 7 }))) + // 7 days is between 48h and 60d -> day-granularity labels + expect(r.format).toBe('days') + }) + + it('uses minute labels for a one-hour span and hour labels for a day', () => { + expect(kscTimespanToStartEndTime('1_hour', NOW).format).toBe('minutes') + expect(kscTimespanToStartEndTime('Today', NOW).format).toBe('hours') + }) + + it('Today spans midnight to next midnight', () => { + const r = kscTimespanToStartEndTime('Today', NOW) + expect(r.startTime).toBe(getUnixTime(startOfDay(NOW))) + expect(r.endTime).toBe(getUnixTime(add(startOfDay(NOW), { days: 1 }))) + }) + + it('Yesterday spans the previous calendar day', () => { + const r = kscTimespanToStartEndTime('Yesterday', NOW) + expect(r.startTime).toBe(getUnixTime(sub(startOfDay(NOW), { days: 1 }))) + expect(r.endTime).toBe(getUnixTime(startOfDay(NOW))) + }) + + it('This Month runs from the 1st to the 1st of next month', () => { + const r = kscTimespanToStartEndTime('This Month', NOW) + expect(r.startTime).toBe(getUnixTime(new Date(2026, 6, 1))) + expect(r.endTime).toBe(getUnixTime(new Date(2026, 7, 1))) + }) + + it('Last Month runs from the 1st of last month to the 1st of this month', () => { + const r = kscTimespanToStartEndTime('Last Month', NOW) + expect(r.startTime).toBe(getUnixTime(new Date(2026, 5, 1))) + expect(r.endTime).toBe(getUnixTime(new Date(2026, 6, 1))) + }) + + it('This Quarter (Q3 for July) runs Jul 1 to Oct 1', () => { + const r = kscTimespanToStartEndTime('This Quarter', NOW) + expect(r.startTime).toBe(getUnixTime(new Date(2026, 6, 1))) + expect(r.endTime).toBe(getUnixTime(new Date(2026, 9, 1))) + }) + + it('Last Quarter (from Q3) runs Apr 1 to Jul 1', () => { + const r = kscTimespanToStartEndTime('Last Quarter', NOW) + expect(r.startTime).toBe(getUnixTime(new Date(2026, 3, 1))) + expect(r.endTime).toBe(getUnixTime(new Date(2026, 6, 1))) + }) + + it('This Year runs Jan 1 to Jan 1 of next year', () => { + const r = kscTimespanToStartEndTime('This Year', NOW) + expect(r.startTime).toBe(getUnixTime(new Date(2026, 0, 1))) + expect(r.endTime).toBe(getUnixTime(new Date(2027, 0, 1))) + }) + + it('This Week starts on the locale first day of week', () => { + const r = kscTimespanToStartEndTime('This Week', NOW) + expect(r.startTime).toBe(getUnixTime(startOfWeek(startOfDay(NOW)))) + }) + + it('falls back to the last day for an unknown timespan', () => { + const r = kscTimespanToStartEndTime('nonsense', NOW) + expect(r.endTime).toBe(getUnixTime(NOW)) + expect(r.startTime).toBe(getUnixTime(sub(NOW, { days: 1 }))) + }) +}) diff --git a/ui/tests/stores/kscStore.test.ts b/ui/tests/stores/kscStore.test.ts new file mode 100644 index 000000000000..d8bc8674e77d --- /dev/null +++ b/ui/tests/stores/kscStore.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useKscStore } from '@/stores/kscStore' +import API from '@/services' +import { KscReport } from '@/types/ksc' + +vi.mock('@/services', () => ({ + default: { + getKscReports: vi.fn(), + getKscReport: vi.fn(), + createKscReport: vi.fn(), + updateKscReport: vi.fn(), + deleteKscReport: vi.fn(), + reloadKscConfig: vi.fn() + } +})) + +const showSnackBar = vi.fn() +vi.mock('@/composables/useSnackbar', () => ({ + default: () => ({ showSnackBar }) +})) + +const report = (over: Partial = {}): KscReport => ({ + id: 0, + label: 'Test', + show_timespan_button: null, + show_graphtype_button: null, + graphs_per_line: 1, + kscGraph: [], + ...over +}) + +describe('useKscStore', () => { + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + store = useKscStore() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('load', () => { + it('populates reports on success', async () => { + const reports = [report(), report({ id: 1, label: 'Second' })] + vi.mocked(API.getKscReports).mockResolvedValue(reports) + + await store.load() + + expect(store.reports).toEqual(reports) + expect(showSnackBar).not.toHaveBeenCalled() + expect(store.loading).toBe(false) + }) + + it('surfaces an error instead of leaving a silent empty list', async () => { + vi.mocked(API.getKscReports).mockRejectedValue(new Error('boom')) + + await store.load() + + expect(showSnackBar).toHaveBeenCalledWith(expect.objectContaining({ error: true })) + expect(store.reports).toEqual([]) + expect(store.loading).toBe(false) + }) + }) + + describe('saveReport', () => { + it('creates when the id is null and reloads', async () => { + vi.mocked(API.createKscReport).mockResolvedValue(7) + vi.mocked(API.getKscReports).mockResolvedValue([]) + + const ok = await store.saveReport(report({ id: null, label: 'New' })) + + expect(ok).toBe(true) + expect(API.createKscReport).toHaveBeenCalledTimes(1) + expect(API.updateKscReport).not.toHaveBeenCalled() + expect(API.getKscReports).toHaveBeenCalledTimes(1) + expect(showSnackBar).toHaveBeenCalledWith(expect.not.objectContaining({ error: true })) + }) + + it('updates when the id is present, passing id and body', async () => { + vi.mocked(API.updateKscReport).mockResolvedValue() + vi.mocked(API.getKscReports).mockResolvedValue([]) + const r = report({ id: 3, label: 'Edited' }) + + const ok = await store.saveReport(r) + + expect(ok).toBe(true) + expect(API.updateKscReport).toHaveBeenCalledWith(3, r) + expect(API.createKscReport).not.toHaveBeenCalled() + }) + + it('returns false and surfaces the error on failure without reloading', async () => { + vi.mocked(API.createKscReport).mockRejectedValue({ response: { data: 'Bad rule' }}) + + const ok = await store.saveReport(report({ id: null })) + + expect(ok).toBe(false) + expect(showSnackBar).toHaveBeenCalledWith(expect.objectContaining({ error: true, msg: 'Bad rule' })) + expect(API.getKscReports).not.toHaveBeenCalled() + }) + }) + + describe('removeReport', () => { + it('refuses to delete a report with no id', async () => { + const ok = await store.removeReport(report({ id: null })) + + expect(ok).toBe(false) + expect(API.deleteKscReport).not.toHaveBeenCalled() + }) + + it('deletes and reloads on success', async () => { + vi.mocked(API.deleteKscReport).mockResolvedValue() + vi.mocked(API.getKscReports).mockResolvedValue([]) + + const ok = await store.removeReport(report({ id: 4 })) + + expect(ok).toBe(true) + expect(API.deleteKscReport).toHaveBeenCalledWith(4) + expect(API.getKscReports).toHaveBeenCalledTimes(1) + }) + + it('returns false and surfaces the error on failure', async () => { + vi.mocked(API.deleteKscReport).mockRejectedValue(new Error('nope')) + + const ok = await store.removeReport(report({ id: 4 })) + + expect(ok).toBe(false) + expect(showSnackBar).toHaveBeenCalledWith(expect.objectContaining({ error: true })) + }) + }) + + describe('reloadConfig', () => { + it('reloads config then refreshes the list', async () => { + vi.mocked(API.reloadKscConfig).mockResolvedValue() + vi.mocked(API.getKscReports).mockResolvedValue([]) + + await store.reloadConfig() + + expect(API.reloadKscConfig).toHaveBeenCalledTimes(1) + expect(API.getKscReports).toHaveBeenCalledTimes(1) + }) + + it('surfaces an error and does not refresh when reload fails', async () => { + vi.mocked(API.reloadKscConfig).mockRejectedValue(new Error('io')) + + await store.reloadConfig() + + expect(showSnackBar).toHaveBeenCalledWith(expect.objectContaining({ error: true })) + expect(API.getKscReports).not.toHaveBeenCalled() + }) + }) + + describe('getReport', () => { + it('delegates to the service', async () => { + const r = report({ id: 9 }) + vi.mocked(API.getKscReport).mockResolvedValue(r) + + const result = await store.getReport(9) + + expect(result).toEqual(r) + expect(API.getKscReport).toHaveBeenCalledWith(9) + }) + }) +})