mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 00:08:55 +00:00
fix: keep synthetics reachable on an org with no data (#14496)
This commit is contained in:
@@ -68,9 +68,7 @@ const router = createRouter({
|
||||
|
||||
// Mock the router methods we need to test
|
||||
const mockRouterPush = vi.fn();
|
||||
const mockRouterReplace = vi.fn().mockResolvedValue(undefined);
|
||||
router.push = mockRouterPush;
|
||||
router.replace = mockRouterReplace;
|
||||
|
||||
const createWrapper = (props = {}, options = {}) => {
|
||||
return mount(SettingsIndex, {
|
||||
@@ -125,7 +123,6 @@ describe("SettingsIndex", () => {
|
||||
mockStore.state.selectedOrganization = { identifier: "test-org" };
|
||||
mockStore.state.zoConfig = { service_streams_enabled: true };
|
||||
mockRouterPush.mockClear();
|
||||
mockRouterReplace.mockClear();
|
||||
|
||||
// Set up router state
|
||||
await router.push("/");
|
||||
@@ -238,24 +235,6 @@ describe("SettingsIndex", () => {
|
||||
});
|
||||
|
||||
describe("Router integration", () => {
|
||||
it("should redirect to /settings/general on settings root route", () => {
|
||||
createWrapper();
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "/settings/general",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should include org_identifier in redirect query", () => {
|
||||
createWrapper();
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: { org_identifier: "test-org" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should map syntheticsLocations route to synthetics_locations tab", () => {
|
||||
// Save the original route reference so we can restore it after the test
|
||||
// (router.push is mocked and won't reset currentRoute on its own).
|
||||
@@ -393,12 +372,6 @@ describe("SettingsIndex", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(wrapper.vm.settingsTab).toBe("general");
|
||||
});
|
||||
|
||||
it("should expose isHub computed property", () => {
|
||||
const wrapper = createWrapper();
|
||||
// On '/' route named 'settings', isHub is true
|
||||
expect(typeof wrapper.vm.isHub).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
|
||||
@@ -109,12 +109,6 @@ export default defineComponent({
|
||||
);
|
||||
const { isMetaOrg } = useIsMetaOrg();
|
||||
|
||||
// /settings (name "settings") with no child → show the hub.
|
||||
const isHub = computed(() => route.name === "settings");
|
||||
const hubRoute = computed(() => ({
|
||||
name: "settings",
|
||||
query: { org_identifier: store.state.selectedOrganization?.identifier },
|
||||
}));
|
||||
const activeSection = computed(() => routeToSettingsTab[route.name as string] ?? "");
|
||||
|
||||
// Form-style sections render in a centered reading column (ConstrainedPage);
|
||||
@@ -127,25 +121,8 @@ export default defineComponent({
|
||||
]);
|
||||
const isConstrainedSection = computed(() => CONSTRAINED_SECTIONS.has(activeSection.value));
|
||||
|
||||
// Full-width sections that still want the shell-owned header (their content
|
||||
// fills the whole width instead of a centered reading column).
|
||||
// The rail is always shown, so the Settings root has no standalone landing —
|
||||
// send it to the first section (General). Also guard meta-only sections.
|
||||
const handleSettingsRouting = () => {
|
||||
const name = router.currentRoute.value.name;
|
||||
if (name === "settings") {
|
||||
// .catch: a rejected navigation (e.g. unit-test router without child
|
||||
// routes) must not surface as an unhandled error.
|
||||
Promise.resolve(
|
||||
router.replace({
|
||||
path: "/settings/general",
|
||||
query: {
|
||||
org_identifier: store.state.selectedOrganization?.identifier,
|
||||
},
|
||||
}),
|
||||
).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const toGeneral = () => {
|
||||
settingsTab.value = "general";
|
||||
router.push({
|
||||
@@ -417,8 +394,6 @@ export default defineComponent({
|
||||
config,
|
||||
settingsTab,
|
||||
isMetaOrg,
|
||||
isHub,
|
||||
hubRoute,
|
||||
activeSection,
|
||||
isConstrainedSection,
|
||||
activeSectionItem,
|
||||
|
||||
@@ -219,21 +219,6 @@ describe("SettingsIndex.vue", () => {
|
||||
mockReplace.mockClear();
|
||||
});
|
||||
|
||||
it("should redirect via replace to general when on settings route", () => {
|
||||
mockRouter.currentRoute.value.name = "settings";
|
||||
wrapper = createWrapper();
|
||||
mockReplace.mockClear();
|
||||
|
||||
wrapper.vm.handleSettingsRouting();
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith({
|
||||
path: "/settings/general",
|
||||
query: {
|
||||
org_identifier: "test-org",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not redirect when on general route", () => {
|
||||
mockRouter.currentRoute.value.name = "general";
|
||||
wrapper = createWrapper();
|
||||
@@ -254,9 +239,7 @@ describe("SettingsIndex.vue", () => {
|
||||
|
||||
wrapper.vm.handleSettingsRouting();
|
||||
|
||||
// nodes route is not "settings" so replace is not called;
|
||||
// notMeta guard may or may not apply depending on zoConfig.meta_org
|
||||
// Without meta_org set, notMeta is falsy → no redirect
|
||||
// Without meta_org set, the notMeta guard is falsy, so nodes is not redirected.
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -280,20 +263,6 @@ describe("SettingsIndex.vue", () => {
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle missing selectedOrganization gracefully", () => {
|
||||
wrapper = createWrapper({ selectedOrganization: null });
|
||||
mockRouter.currentRoute.value.name = "settings";
|
||||
mockReplace.mockClear();
|
||||
|
||||
expect(() => wrapper.vm.handleSettingsRouting()).not.toThrow();
|
||||
expect(mockReplace).toHaveBeenCalledWith({
|
||||
path: "/settings/general",
|
||||
query: {
|
||||
org_identifier: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not redirect when on other non-settings routes", () => {
|
||||
mockRouter.currentRoute.value.name = "alertDestinations";
|
||||
wrapper = createWrapper();
|
||||
@@ -328,17 +297,6 @@ describe("SettingsIndex.vue", () => {
|
||||
});
|
||||
|
||||
describe("Lifecycle Hooks", () => {
|
||||
it("should call handleSettingsRouting on mount (settings route)", () => {
|
||||
mockRouter.currentRoute.value.name = "settings";
|
||||
wrapper = createWrapper();
|
||||
|
||||
// During mount, handleSettingsRouting is called via onBeforeMount
|
||||
// It should have triggered a replace call for the "settings" route
|
||||
expect(mockReplace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "/settings/general" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not redirect during mount when on a valid child route", () => {
|
||||
mockRouter.currentRoute.value.name = "general";
|
||||
mockReplace.mockClear();
|
||||
@@ -450,17 +408,6 @@ describe("SettingsIndex.vue", () => {
|
||||
});
|
||||
|
||||
describe("Integration Tests", () => {
|
||||
it("should handle workflow for non-enterprise org", async () => {
|
||||
mockRouter.currentRoute.value.name = "settings";
|
||||
wrapper = createWrapper();
|
||||
mockReplace.mockClear();
|
||||
|
||||
wrapper.vm.handleSettingsRouting();
|
||||
expect(mockReplace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "/settings/general" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should have stable activeSection after mount on general", () => {
|
||||
mockRouter.currentRoute.value.name = "general";
|
||||
wrapper = createWrapper();
|
||||
@@ -485,15 +432,6 @@ describe("SettingsIndex.vue", () => {
|
||||
wrapper = createWrapper();
|
||||
expect(() => wrapper.vm.handleSettingsRouting()).not.toThrow();
|
||||
});
|
||||
|
||||
it("should handle router replace failures gracefully", () => {
|
||||
mockRouter.currentRoute.value.name = "settings";
|
||||
// replace returns a rejected promise — the component wraps it in Promise.resolve().catch()
|
||||
mockReplace.mockReturnValue(Promise.reject(new Error("nav failed")));
|
||||
expect(() => {
|
||||
wrapper = createWrapper();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Computed Properties Reactivity", () => {
|
||||
|
||||
@@ -280,6 +280,29 @@ describe("useEnterpriseRoutes.ts", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// Synthetics produces an empty org's first data, so the empty-data gate must not block it.
|
||||
it.each([
|
||||
"synthetics",
|
||||
"synthetics-add",
|
||||
"synthetics-edit",
|
||||
"synthetics-status-page-edit",
|
||||
"synthetic-private-location",
|
||||
"synthetic-monitor-results",
|
||||
"synthetics-run-detail",
|
||||
])("should flag the %s route as allowOnEmptyData", (name) => {
|
||||
const routes = useEnterpriseRoutes();
|
||||
const route = routes.find((r: any) => r.name === name);
|
||||
expect(route).toBeDefined();
|
||||
expect(route.meta?.allowOnEmptyData).toBe(true);
|
||||
});
|
||||
|
||||
// The flag on the parent is inherited by every IAM child through merged meta.
|
||||
it("should flag the iam route as allowOnEmptyData", () => {
|
||||
const routes = useEnterpriseRoutes();
|
||||
const iamRoute = routes.find((route: any) => route.name === "iam");
|
||||
expect(iamRoute.meta?.allowOnEmptyData).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cloud Configuration", () => {
|
||||
@@ -386,6 +409,22 @@ describe("useEnterpriseRoutes.ts", () => {
|
||||
const routes = useEnterpriseRoutes();
|
||||
expect(routes.length).toBe(18);
|
||||
});
|
||||
|
||||
// On-call is configured before any data flows, so the empty-data gate must not block it.
|
||||
it.each([
|
||||
"onCallResponses",
|
||||
"onCallResponseDetail",
|
||||
"onCallMine",
|
||||
"onCallTeams",
|
||||
"onCallTeamDetail",
|
||||
"onCallPolicies",
|
||||
"onCallRouting",
|
||||
])("should flag the %s route as allowOnEmptyData", (name) => {
|
||||
const routes = useEnterpriseRoutes();
|
||||
const route = routes.find((r: any) => r.name === name);
|
||||
expect(route).toBeDefined();
|
||||
expect(route.meta?.allowOnEmptyData).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Both Cloud and Enterprise Configuration", () => {
|
||||
|
||||
@@ -112,6 +112,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "iam",
|
||||
name: "iam",
|
||||
component: IdentityAccessManagement,
|
||||
meta: { allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
routeGuard(to, from, next);
|
||||
},
|
||||
@@ -215,7 +216,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "synthetics",
|
||||
name: "synthetics",
|
||||
component: () => import("@/views/SyntheticMonitoring.vue"),
|
||||
meta: { titleKey: "menu.synthetic" },
|
||||
meta: { titleKey: "menu.synthetic", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
syntheticsRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -226,7 +227,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "synthetics/add",
|
||||
name: "synthetics-add",
|
||||
component: () => import("@/views/synthetics/CreateCheck.vue"),
|
||||
meta: { titleKey: "routeTitles.addCheck" },
|
||||
meta: { titleKey: "routeTitles.addCheck", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
syntheticsRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -235,7 +236,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "synthetics/edit/:id",
|
||||
name: "synthetics-edit",
|
||||
component: () => import("@/views/synthetics/CreateCheck.vue"),
|
||||
meta: { titleKey: "synthetics.results.editCheck" },
|
||||
meta: { titleKey: "synthetics.results.editCheck", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
syntheticsRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -244,7 +245,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "synthetics/status-pages/edit/:id",
|
||||
name: "synthetics-status-page-edit",
|
||||
component: () => import("@/views/synthetics/status-pages/StatusPageEditor.vue"),
|
||||
meta: { titleKey: "statusPages.editTitle" },
|
||||
meta: { titleKey: "statusPages.editTitle", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
syntheticsRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -253,7 +254,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "synthetic/private-locations/:id",
|
||||
name: "synthetic-private-location",
|
||||
component: () => import("@/views/synthetics/PrivateLocationDetail.vue"),
|
||||
meta: { titleKey: "synthetics.privateLocations.detail.title" },
|
||||
meta: { titleKey: "synthetics.privateLocations.detail.title", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
privateLocationRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -262,7 +263,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "synthetics/:id/results",
|
||||
name: "synthetic-monitor-results",
|
||||
component: () => import("@/views/synthetics/MonitorResults.vue"),
|
||||
meta: { titleKey: "synthetics.results.title" },
|
||||
meta: { titleKey: "synthetics.results.title", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
syntheticsRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -271,7 +272,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "synthetics/:id/results/run/:runId/:executionId",
|
||||
name: "synthetics-run-detail",
|
||||
component: () => import("@/views/synthetics/RunDetail.vue"),
|
||||
meta: { titleKey: "synthetics.runDetail.title" },
|
||||
meta: { titleKey: "synthetics.runDetail.title", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
syntheticsRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -282,14 +283,13 @@ const useEnterpriseRoutes = () => {
|
||||
//the above are the routes that we support for oss including both enterprise and cloud
|
||||
|
||||
if (config.isCloud == "true" || config.isEnterprise == "true") {
|
||||
// Seven on-call routes, one guard, one gate expression. `titleKey` rather
|
||||
// than `title` so the browser tab is translated like everything else.
|
||||
// On-call is configured before any data flows, so every route here stays open on an empty org.
|
||||
routes.push(
|
||||
{
|
||||
path: "oncall/responses",
|
||||
name: "onCallResponses",
|
||||
component: OnCallResponses,
|
||||
meta: { titleKey: "oncall.responsesTitle" },
|
||||
meta: { titleKey: "oncall.responsesTitle", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
oncallRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -298,7 +298,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "oncall/responses/:responseId",
|
||||
name: "onCallResponseDetail",
|
||||
component: OnCallResponseDetail,
|
||||
meta: { titleKey: "oncall.responseDetail" },
|
||||
meta: { titleKey: "oncall.responseDetail", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
oncallRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -316,7 +316,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "oncall/me",
|
||||
name: "onCallMine",
|
||||
component: OnCallMine,
|
||||
meta: { titleKey: "oncall.mineTitle" },
|
||||
meta: { titleKey: "oncall.mineTitle", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
oncallRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -325,7 +325,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "oncall/teams",
|
||||
name: "onCallTeams",
|
||||
component: OnCallTeams,
|
||||
meta: { titleKey: "oncall.teamsTitle" },
|
||||
meta: { titleKey: "oncall.teamsTitle", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
oncallRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -343,7 +343,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "oncall/teams/:teamId/:tab(overview|schedule|members|policy|escalation|ownership|routing)?",
|
||||
name: "onCallTeamDetail",
|
||||
component: OnCallTeamDetail,
|
||||
meta: { titleKey: "oncall.teamDetail" },
|
||||
meta: { titleKey: "oncall.teamDetail", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
oncallRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -352,7 +352,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "oncall/policies",
|
||||
name: "onCallPolicies",
|
||||
component: OnCallPolicies,
|
||||
meta: { titleKey: "oncall.policiesTitle" },
|
||||
meta: { titleKey: "oncall.policiesTitle", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
oncallRouteGuard(to, from, next);
|
||||
},
|
||||
@@ -361,7 +361,7 @@ const useEnterpriseRoutes = () => {
|
||||
path: "oncall/routing",
|
||||
name: "onCallRouting",
|
||||
component: OnCallRouting,
|
||||
meta: { titleKey: "oncall.routingTitle" },
|
||||
meta: { titleKey: "oncall.routingTitle", allowOnEmptyData: true },
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
oncallRouteGuard(to, from, next);
|
||||
},
|
||||
|
||||
@@ -143,6 +143,12 @@ describe("useIngestionRoutes", () => {
|
||||
expect(Array.isArray(mainRoute.children)).toBe(true);
|
||||
});
|
||||
|
||||
// Ingestion is where an empty org gets data, so the empty-data gate must not block it.
|
||||
it("should flag the ingestion route as allowOnEmptyData", () => {
|
||||
const routes = useIngestionRoutes();
|
||||
expect(routes[0].meta?.allowOnEmptyData).toBe(true);
|
||||
});
|
||||
|
||||
it("should have all expected main child routes", () => {
|
||||
const routes = useIngestionRoutes();
|
||||
const mainRoute = routes[0];
|
||||
|
||||
@@ -145,6 +145,7 @@ const useIngestionRoutes = () => {
|
||||
component: Ingestion,
|
||||
meta: {
|
||||
titleKey: "menu.ingestionText",
|
||||
allowOnEmptyData: true,
|
||||
},
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
routeGuard(to, from, next);
|
||||
|
||||
@@ -122,6 +122,15 @@ describe("useManagementRoutes", () => {
|
||||
const routes = useManagementRoutes();
|
||||
expect(routes[0].children.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
// Bare /settings never lands; the record redirects to general and the query must survive the hop.
|
||||
it("should redirect settings to general, preserving the query", () => {
|
||||
const routes = useManagementRoutes();
|
||||
expect(routes[0].redirect({ query: { org_identifier: "o" } })).toEqual({
|
||||
name: "general",
|
||||
query: { org_identifier: "o" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Base Children Routes", () => {
|
||||
@@ -137,6 +146,12 @@ describe("useManagementRoutes", () => {
|
||||
expect(generalRoute.path).toBe("general");
|
||||
});
|
||||
|
||||
// General hosts the Danger Zone; an admin must reach it to delete an empty org.
|
||||
it("should flag the general route as allowOnEmptyData", () => {
|
||||
const generalRoute = routes[0].children.find((child: any) => child.name === "general");
|
||||
expect(generalRoute.meta?.allowOnEmptyData).toBe(true);
|
||||
});
|
||||
|
||||
it("should have organizationSettings route", () => {
|
||||
const orgRoute = routes[0].children.find(
|
||||
(child: any) => child.name === "organizationSettings",
|
||||
@@ -398,9 +413,19 @@ describe("useManagementRoutes", () => {
|
||||
expect(synthRoute.meta).toEqual({
|
||||
keepAlive: true,
|
||||
titleKey: "routeTitles.syntheticsLocations",
|
||||
allowOnEmptyData: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Locations are a prerequisite of the first check, so the empty-data gate must not block them.
|
||||
it("should flag the syntheticsLocations route as allowOnEmptyData", () => {
|
||||
const routes = useManagementRoutes();
|
||||
const synthRoute = routes[0].children.find(
|
||||
(child: any) => child.name === "syntheticsLocations",
|
||||
);
|
||||
expect(synthRoute.meta?.allowOnEmptyData).toBe(true);
|
||||
});
|
||||
|
||||
// Synthetics ships in OSS, so the route registers in every build — the
|
||||
// feature gate is the backend `/config` flag, not the build type.
|
||||
it("should have syntheticsLocations route in an OSS build", () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ const useManagementRoutes = () => {
|
||||
keepAlive: true,
|
||||
titleKey: "menu.settings",
|
||||
},
|
||||
redirect: (to: any) => ({ name: "general", query: to.query }),
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
routeGuard(to, from, next);
|
||||
},
|
||||
@@ -22,6 +23,7 @@ const useManagementRoutes = () => {
|
||||
name: "general",
|
||||
meta: {
|
||||
titleKey: "settings.groupGeneral",
|
||||
allowOnEmptyData: true,
|
||||
},
|
||||
component: () => import("@/components/settings/General.vue"),
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
@@ -73,9 +75,11 @@ const useManagementRoutes = () => {
|
||||
path: "synthetics_locations",
|
||||
name: "syntheticsLocations",
|
||||
component: () => import("@/components/settings/SyntheticsLocationsList.vue"),
|
||||
// Locations are set up before the first check can run, so an empty org must reach this.
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
titleKey: "routeTitles.syntheticsLocations",
|
||||
allowOnEmptyData: true,
|
||||
},
|
||||
beforeEnter(to: any, from: any, next: any) {
|
||||
routeGuard(to, from, next);
|
||||
|
||||
@@ -228,7 +228,7 @@ import {
|
||||
getImageURL,
|
||||
invalidateLoginData,
|
||||
shouldPaywallRoute,
|
||||
emptyDataAllowedPaths,
|
||||
isEmptyDataExempt,
|
||||
} from "../utils/zincutils";
|
||||
|
||||
import {
|
||||
@@ -954,18 +954,7 @@ export default defineComponent({
|
||||
});
|
||||
if (response.list.length == 0) {
|
||||
store.dispatch("setIsDataIngested", false);
|
||||
// IAM is org-setup, not data consumption — don't bounce out of IAM
|
||||
// screens just because no streams exist yet. General Settings is exempt
|
||||
// because it hosts the Danger Zone: switching to an empty org must still
|
||||
// leave the admin able to delete it. Mirrors the routeGuard exemptions —
|
||||
// General only, not the rest of the Settings tree.
|
||||
const currentPath = router.currentRoute.value.path || "";
|
||||
if (
|
||||
currentPath.indexOf("/iam") !== -1 ||
|
||||
emptyDataAllowedPaths.indexOf(currentPath.replace(/\/$/, "")) !== -1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isEmptyDataExempt(router.currentRoute.value)) return;
|
||||
toast({
|
||||
variant: "warning",
|
||||
message: t("toastMessages.layouts.ingestionNotStarted"),
|
||||
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
// Copyright 2026 OpenObserve Inc.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import "vue-router";
|
||||
|
||||
declare module "vue-router" {
|
||||
interface RouteMeta {
|
||||
allowOnEmptyData?: boolean;
|
||||
}
|
||||
}
|
||||
+135
-53
@@ -53,6 +53,7 @@ import {
|
||||
trialPeriodAllowedPath,
|
||||
trialPaywallAllowedPath,
|
||||
isTrialExpired,
|
||||
isEmptyDataExempt,
|
||||
getUserInfo,
|
||||
invalidateLoginData,
|
||||
getDecodedAccessToken,
|
||||
@@ -133,7 +134,6 @@ describe("trialPeriodAllowedPath", () => {
|
||||
expect(trialPeriodAllowedPath).toHaveLength(4);
|
||||
});
|
||||
|
||||
// The empty-data guard reads this list, so "general" must stay out of it.
|
||||
it("does not contain general", () => {
|
||||
expect(trialPeriodAllowedPath).not.toContain("general");
|
||||
});
|
||||
@@ -152,12 +152,31 @@ describe("trialPeriodAllowedPath", () => {
|
||||
trialPeriodAllowedPath.forEach((p) => expect(trialPaywallAllowedPath).toContain(p));
|
||||
});
|
||||
|
||||
// The empty-data guard reads this list, so the shell must stay out of it.
|
||||
it("does not contain the settings shell in the empty-data list", () => {
|
||||
it("does not contain the settings shell", () => {
|
||||
expect(trialPeriodAllowedPath).not.toContain("settings");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isEmptyDataExempt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("isEmptyDataExempt", () => {
|
||||
it("is true when the route meta carries allowOnEmptyData: true", () => {
|
||||
expect(isEmptyDataExempt({ meta: { allowOnEmptyData: true } })).toBe(true);
|
||||
});
|
||||
|
||||
// Merged meta is child-over-parent, so a child can re-gate itself with `false`.
|
||||
it.each([
|
||||
["a route with no meta", {}],
|
||||
["a route whose meta lacks the flag", { meta: { titleKey: "menu.logs" } }],
|
||||
["a child override of false", { meta: { allowOnEmptyData: false } }],
|
||||
["a truthy non-boolean flag", { meta: { allowOnEmptyData: "true" } }],
|
||||
])("is false for %s", (_label, route) => {
|
||||
expect(isEmptyDataExempt(route)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getUserInfo
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -406,7 +425,6 @@ describe("routeGuard", () => {
|
||||
expect(mockNext).toHaveBeenCalledWith(expect.objectContaining({ name: "plans" }));
|
||||
});
|
||||
|
||||
// The nav resolves to name "settings", not "general"; paywalling it hides the feature.
|
||||
it("lets the nav settings shell through", async () => {
|
||||
(config as any).isCloud = "true";
|
||||
mockStore = buildExpiredTrialStore();
|
||||
@@ -520,7 +538,7 @@ describe("routeGuard", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// emptyDataAllowedPaths is what exempts /settings/general here, not the name list.
|
||||
// The route's own meta flag is what exempts /settings/general here, not its name.
|
||||
it("lets /settings/general through on a no-data org", async () => {
|
||||
(config as any).isCloud = "false";
|
||||
vi.mocked(organizationService.get_organization_summary).mockResolvedValue({
|
||||
@@ -529,13 +547,18 @@ describe("routeGuard", () => {
|
||||
mockStore = buildNoDataStore();
|
||||
vi.mocked(useStore).mockReturnValue(mockStore as any);
|
||||
|
||||
await routeGuard({ name: "general", path: "/settings/general" }, {}, mockNext);
|
||||
await routeGuard(
|
||||
{ name: "general", path: "/settings/general", meta: { allowOnEmptyData: true } },
|
||||
{},
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalledWith();
|
||||
expect(mockNext).not.toHaveBeenCalledWith({ path: "/ingestion" });
|
||||
});
|
||||
|
||||
// Same name, uncovered path: fails if the name list ever does the exempting.
|
||||
// Same name as the flagged fixture above, no flag: fails if the name ever does the exempting.
|
||||
it("does not exempt a general-named route at an uncovered path", async () => {
|
||||
(config as any).isCloud = "false";
|
||||
vi.mocked(organizationService.get_organization_summary).mockResolvedValue({
|
||||
@@ -635,7 +658,11 @@ describe("routeGuard", () => {
|
||||
});
|
||||
vi.mocked(useStore).mockReturnValue(mockStore as any);
|
||||
|
||||
await routeGuard({ name: "ingestion", path: "/ingestion" }, {}, mockNext);
|
||||
await routeGuard(
|
||||
{ name: "ingestion", path: "/ingestion", meta: { allowOnEmptyData: true } },
|
||||
{},
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
@@ -655,7 +682,11 @@ describe("routeGuard", () => {
|
||||
});
|
||||
vi.mocked(useStore).mockReturnValue(mockStore as any);
|
||||
|
||||
await routeGuard({ name: "iam", path: "/iam" }, {}, mockNext);
|
||||
await routeGuard(
|
||||
{ name: "iam", path: "/iam", meta: { allowOnEmptyData: true } },
|
||||
{},
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
@@ -673,32 +704,7 @@ describe("routeGuard", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// /settings/general hosts the Danger Zone, and an org with nothing ingested is
|
||||
// the one an admin is most likely to delete — bouncing to /ingestion would
|
||||
// leave no way to. "/settings" is the nav's landing path before it redirects
|
||||
// to general, so it has to survive the guard too.
|
||||
it.each([
|
||||
["settings landing", "settings", "/settings"],
|
||||
["general settings", "general", "/settings/general"],
|
||||
["general settings, trailing slash", "general", "/settings/general/"],
|
||||
])("calls next() directly for %s", async (_label, name, path) => {
|
||||
(config as any).isCloud = "false";
|
||||
vi.mocked(organizationService.get_organization_summary).mockResolvedValue({
|
||||
data: { streams: { num_streams: 0 } },
|
||||
});
|
||||
mockStore = buildEmptyDataStore();
|
||||
vi.mocked(useStore).mockReturnValue(mockStore as any);
|
||||
|
||||
await routeGuard({ name, path }, {}, mockNext);
|
||||
|
||||
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockNext).not.toHaveBeenCalledWith({ path: "/ingestion" });
|
||||
});
|
||||
|
||||
// The exemption is deliberately only the two paths above — the rest of the
|
||||
// Settings tree shows no ingested data either, but it is not needed to escape
|
||||
// an empty org, so it stays behind the ingestion redirect.
|
||||
// Settings children carry no flag, so they stay gated even though they show no data.
|
||||
it.each([
|
||||
["organizationSettings", "/settings/organization"],
|
||||
["license", "/settings/license"],
|
||||
@@ -716,19 +722,26 @@ describe("routeGuard", () => {
|
||||
expect(mockNext).toHaveBeenCalledWith({ path: "/ingestion" });
|
||||
});
|
||||
|
||||
// I14: on-call is configured BEFORE any data flows — teams, schedules and
|
||||
// routing are exactly what a fresh org sets up first. Bouncing those clicks
|
||||
// to /ingestion made every on-call screen unreachable by navigation (a
|
||||
// direct URL worked only because a cold load races zoConfig). The whole
|
||||
// subtree is exempt, including the dynamic-segment routes the exact-match
|
||||
// list cannot express.
|
||||
// Synthetics produces an empty org's first data and on-call is set up before any flows.
|
||||
it.each([
|
||||
["triage list", "onCallResponses", "/oncall/responses"],
|
||||
["team list", "onCallTeams", "/oncall/teams"],
|
||||
["team detail with params", "onCallTeamDetail", "/oncall/teams/team_1/schedule"],
|
||||
["org routing", "onCallRouting", "/oncall/routing"],
|
||||
["trailing slash", "onCallTeams", "/oncall/teams/"],
|
||||
])("calls next() directly for on-call %s", async (_label, name, path) => {
|
||||
["synthetics", { name: "synthetics", path: "/synthetics", meta: { allowOnEmptyData: true } }],
|
||||
[
|
||||
"synthetic-private-location",
|
||||
{
|
||||
name: "synthetic-private-location",
|
||||
path: "/synthetic/private-locations/abc",
|
||||
meta: { allowOnEmptyData: true },
|
||||
},
|
||||
],
|
||||
[
|
||||
"onCallTeamDetail",
|
||||
{
|
||||
name: "onCallTeamDetail",
|
||||
path: "/oncall/teams/team_1/schedule",
|
||||
meta: { allowOnEmptyData: true },
|
||||
},
|
||||
],
|
||||
])("calls next() directly for the flagged %s route", async (_label, route) => {
|
||||
(config as any).isCloud = "false";
|
||||
vi.mocked(organizationService.get_organization_summary).mockResolvedValue({
|
||||
data: { streams: { num_streams: 0 } },
|
||||
@@ -736,16 +749,84 @@ describe("routeGuard", () => {
|
||||
mockStore = buildEmptyDataStore();
|
||||
vi.mocked(useStore).mockReturnValue(mockStore as any);
|
||||
|
||||
await routeGuard({ name, path }, {}, mockNext);
|
||||
await routeGuard(route, {}, mockNext);
|
||||
|
||||
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockNext).not.toHaveBeenCalledWith({ path: "/ingestion" });
|
||||
expect(mockNext).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
// The prefix is a path SEGMENT, not a substring — a route that merely
|
||||
// starts with the same letters must not inherit the exemption.
|
||||
it("still redirects a near-miss path that only shares the prefix's letters", async () => {
|
||||
// The flag exempts a route from the empty-data gate only, never from the paywall.
|
||||
it("still redirects a flagged route to plans when the cloud trial is expired", async () => {
|
||||
(config as any).isCloud = "true";
|
||||
mockStore = buildMockStore({
|
||||
state: {
|
||||
organizationData: {
|
||||
organizationSettings: {
|
||||
free_trial_expiry: (Date.now() - 30 * 24 * 60 * 60 * 1000) * 1000,
|
||||
},
|
||||
isDataIngested: false,
|
||||
},
|
||||
selectedOrganization: { identifier: "my-org" },
|
||||
zoConfig: { restricted_routes_on_empty_data: true },
|
||||
},
|
||||
});
|
||||
vi.mocked(useStore).mockReturnValue(mockStore as any);
|
||||
|
||||
await routeGuard(
|
||||
{ name: "synthetics", path: "/synthetics", meta: { allowOnEmptyData: true } },
|
||||
{},
|
||||
mockNext,
|
||||
);
|
||||
|
||||
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockNext).toHaveBeenCalledWith({
|
||||
name: "plans",
|
||||
query: { org_identifier: "my-org" },
|
||||
});
|
||||
});
|
||||
|
||||
// Paywall runs before the empty-data gate; an empty org must not turn a paywall into a bounce.
|
||||
it("still redirects an unflagged route to plans when the cloud trial is expired on an empty org", async () => {
|
||||
(config as any).isCloud = "true";
|
||||
mockStore = buildMockStore({
|
||||
state: {
|
||||
organizationData: {
|
||||
organizationSettings: {
|
||||
free_trial_expiry: (Date.now() - 30 * 24 * 60 * 60 * 1000) * 1000,
|
||||
},
|
||||
isDataIngested: false,
|
||||
},
|
||||
selectedOrganization: { identifier: "my-org" },
|
||||
zoConfig: { restricted_routes_on_empty_data: true },
|
||||
},
|
||||
});
|
||||
vi.mocked(useStore).mockReturnValue(mockStore as any);
|
||||
|
||||
await routeGuard({ name: "logs", path: "/logs" }, {}, mockNext);
|
||||
|
||||
expect(organizationService.get_organization_summary).not.toHaveBeenCalled();
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockNext).toHaveBeenCalledWith({
|
||||
name: "plans",
|
||||
query: { org_identifier: "my-org" },
|
||||
});
|
||||
});
|
||||
|
||||
// Only the meta flag exempts; a matching name or path prefix does not.
|
||||
it.each([
|
||||
["an unflagged general route", { name: "general", path: "/settings/general" }],
|
||||
["an unflagged route on a synthetics path", { name: "x", path: "/synthetics/foo" }],
|
||||
["an unflagged near-miss of the on-call path", { name: "oncallish", path: "/oncallish" }],
|
||||
["a route named users under /iam", { name: "users", path: "/iam/users" }],
|
||||
["an unflagged route named iam", { name: "iam", path: "/access" }],
|
||||
[
|
||||
"an unflagged route under /ingestion",
|
||||
{ name: "curl", path: "/ingestion/custom/logs/curl" },
|
||||
],
|
||||
["the unflagged settings shell", { name: "settings", path: "/settings" }],
|
||||
])("redirects %s to /ingestion", async (_label, route) => {
|
||||
(config as any).isCloud = "false";
|
||||
vi.mocked(organizationService.get_organization_summary).mockResolvedValue({
|
||||
data: { streams: { num_streams: 0 } },
|
||||
@@ -753,8 +834,9 @@ describe("routeGuard", () => {
|
||||
mockStore = buildEmptyDataStore();
|
||||
vi.mocked(useStore).mockReturnValue(mockStore as any);
|
||||
|
||||
await routeGuard({ name: "oncallish", path: "/oncallish" }, {}, mockNext);
|
||||
await routeGuard(route, {}, mockNext);
|
||||
|
||||
expect(mockNext).toHaveBeenCalledTimes(1);
|
||||
expect(mockNext).toHaveBeenCalledWith({ path: "/ingestion" });
|
||||
});
|
||||
});
|
||||
|
||||
+6
-34
@@ -2,39 +2,15 @@
|
||||
|
||||
import config from "../aws-exports";
|
||||
import { useStore } from "vuex";
|
||||
import type { RouteMeta } from "vue-router";
|
||||
import userService from "@/services/users";
|
||||
import organizationService from "@/services/organizations";
|
||||
import { b64DecodeUnicode, b64EncodeStandard, b64DecodeStandard } from "@/utils/formatters";
|
||||
import { useLocalUserInfo } from "@/utils/storage";
|
||||
import { getUUID, getUUIDv7 } from "@/utils/uuid";
|
||||
|
||||
// Exact paths that stay reachable when the org has ingested nothing yet. The
|
||||
// empty-data redirect exists to push people toward ingestion rather than show
|
||||
// them empty data views, and that still applies to most of Settings — but an org
|
||||
// with nothing ingested is precisely the one an admin is most likely to want to
|
||||
// delete, and the Danger Zone lives on /settings/general. "/settings" is included
|
||||
// because the nav's Settings entry lands there before redirecting to general.
|
||||
// Matched exactly, not by prefix, so the rest of the Settings tree stays gated.
|
||||
export const emptyDataAllowedPaths = ["/settings", "/settings/general"];
|
||||
|
||||
// Route SUBTREES that stay reachable with nothing ingested. On-call is
|
||||
// configured before data flows — teams, schedules, escalation and routing are
|
||||
// exactly what a fresh org sets up first, and bouncing those clicks to
|
||||
// /ingestion made every on-call screen unreachable by navigation (I14). A
|
||||
// prefix list because these routes carry dynamic segments (:teamId, :tab),
|
||||
// which the exact-match list above cannot express. A prefix admits its whole
|
||||
// subtree — add a screen's root here only when everything under it is
|
||||
// configuration rather than a data view.
|
||||
export const emptyDataAllowedPrefixes = ["/oncall"];
|
||||
|
||||
// "/settings/general/" and "/settings/general" are the same page; an exact match
|
||||
// must not hinge on a trailing slash.
|
||||
const normalizePath = (path: string) =>
|
||||
path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
|
||||
export const trialPeriodAllowedPath = ["iam", "users", "organizations", "invitations"];
|
||||
|
||||
// Kept out of the empty-data guard; "settings" only redirects on to general.
|
||||
export const trialPaywallAllowedPath = [...trialPeriodAllowedPath, "settings", "general"];
|
||||
|
||||
export const getUserInfo = (loginString: string) => {
|
||||
@@ -133,6 +109,10 @@ export const shouldPaywallRoute = (expiry: unknown, routeName: unknown): boolean
|
||||
isTrialExpired(expiry) &&
|
||||
trialPaywallAllowedPath.indexOf(routeName as string) === -1;
|
||||
|
||||
// Org-setup and data-producing surfaces declare their empty-data exemption per route, in meta.
|
||||
export const isEmptyDataExempt = (route: { meta?: RouteMeta | Record<string, unknown> }): boolean =>
|
||||
route.meta?.allowOnEmptyData === true;
|
||||
|
||||
export const routeGuard = async (to: any, from: any, next: any) => {
|
||||
const store = useStore();
|
||||
if (
|
||||
@@ -151,15 +131,7 @@ export const routeGuard = async (to: any, from: any, next: any) => {
|
||||
}
|
||||
|
||||
if (
|
||||
to.path.indexOf("/ingestion") === -1 &&
|
||||
to.path.indexOf("/iam") === -1 &&
|
||||
to.name !== "iam" &&
|
||||
emptyDataAllowedPaths.indexOf(normalizePath(to.path)) === -1 &&
|
||||
!emptyDataAllowedPrefixes.some(
|
||||
(prefix) =>
|
||||
normalizePath(to.path) === prefix || normalizePath(to.path).startsWith(prefix + "/"),
|
||||
) &&
|
||||
trialPeriodAllowedPath.indexOf(to.name) === -1 &&
|
||||
!isEmptyDataExempt(to) &&
|
||||
Object.prototype.hasOwnProperty.call(store.state.zoConfig, "restricted_routes_on_empty_data") &&
|
||||
store.state.zoConfig.restricted_routes_on_empty_data === true &&
|
||||
store.state.organizationData.isDataIngested === false
|
||||
|
||||
Reference in New Issue
Block a user