Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .yarn/install-state.gz
Binary file not shown.
3 changes: 3 additions & 0 deletions Dockerfile.Github
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
FROM image-registry.apps.silver.devops.gov.bc.ca/6cdc9e-tools/eagle-admin-nginx-runtime

# Copy default.conf with analytics reverse proxy configuration
COPY openshift/templates/nginx-runtime/default.conf /etc/nginx/conf.d/default.conf

COPY dist /tmp/app/dist/admin

CMD ["/usr/libexec/s2i/run"]
3 changes: 2 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@
"builder": "@angular/build:dev-server",
"options": {
"port": 4200,
"buildTarget": "base-app2:build:development"
"buildTarget": "base-app2:build:development",
"proxyConfig": "proxy.conf.json"
},
"configurations": {
"production": {
Expand Down
4 changes: 2 additions & 2 deletions openshift/templates/nginx-runtime/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ server {

# Reverse proxy for Penguin Analytics
# Forwards /api/analytics requests to the penguin-analytics API service
# Uses template placeholders: %PENGUIN_ANALYTICS_HOST%:%PENGUIN_ANALYTICS_PORT%
# Uses Kubernetes DNS: penguin-analytics-api resolves to the service in the same namespace
location /api/analytics {
proxy_pass http://%PENGUIN_ANALYTICS_HOST%:%PENGUIN_ANALYTICS_PORT%/events;
proxy_pass http://penguin-analytics-api:3000/events;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Expand Down
20 changes: 3 additions & 17 deletions openshift/templates/nginx-runtime/s2i/bin/run
Original file line number Diff line number Diff line change
@@ -1,23 +1,9 @@
#!/bin/bash
echo run script starting...

# Configure Penguin Analytics host and port (for dynamic nginx reverse proxy)
# PENGUIN_ANALYTICS_HOST should be set per environment:
# - Same namespace: penguin-analytics-api (service name only)
# - Cross-namespace: penguin-analytics-api.<namespace>.svc.cluster.local
# If not set, defaults to same-namespace service name
export PENGUIN_ANALYTICS_HOST=${PENGUIN_ANALYTICS_HOST:-penguin-analytics-api}
export PENGUIN_ANALYTICS_PORT=${PENGUIN_ANALYTICS_PORT:-3000}
export PENGUIN_ANALYTICS_URL=${PENGUIN_ANALYTICS_URL:-/api/analytics}

echo "---> Configuring Penguin Analytics: ${PENGUIN_ANALYTICS_HOST}:${PENGUIN_ANALYTICS_PORT}"

sed "s~%RealIpFrom%~${RealIpFrom:-172.51.0.0/16}~g; s~%IpFilterRules%~${IpFilterRules}~g; s~%AdditionalRealIpFromRules%~${AdditionalRealIpFromRules}~g; s~%PENGUIN_ANALYTICS_HOST%~${PENGUIN_ANALYTICS_HOST}~g; s~%PENGUIN_ANALYTICS_PORT%~${PENGUIN_ANALYTICS_PORT}~g" /tmp/nginx.conf.template > /etc/nginx/nginx.conf

# Substitute analytics URL in env.js for the Angular app
if [ -f /tmp/app/dist/admin/env.js ]; then
echo "---> Configuring analytics URL: ${PENGUIN_ANALYTICS_URL}"
sed -i "s~%PENGUIN_ANALYTICS_URL%~${PENGUIN_ANALYTICS_URL}~g" /tmp/app/dist/admin/env.js
# Process nginx.conf template (if exists)
if [ -f /tmp/nginx.conf.template ]; then
sed "s~%RealIpFrom%~${RealIpFrom:-172.51.0.0/16}~g; s~%IpFilterRules%~${IpFilterRules}~g; s~%AdditionalRealIpFromRules%~${AdditionalRealIpFromRules}~g" /tmp/nginx.conf.template > /etc/nginx/nginx.conf
fi

if [ -n "$HTTP_BASIC_USERNAME" ] && [ -n "$HTTP_BASIC_PASSWORD" ]; then
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@ng-select/ng-select": "^21.1.0",
"@popperjs/core": "^2.11.8",
"@tinymce/tinymce-angular": "^9.0.0",
"analytics": "^0.8.14",
"bootstrap": "^5.3.7",
"jszip": "^3.10.1",
"keycloak-angular": "^21.0.0",
Expand Down
11 changes: 11 additions & 0 deletions proxy.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"/api/analytics": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true,
"pathRewrite": {
"^/api/analytics": "/events"
},
"logLevel": "debug"
}
}
48 changes: 46 additions & 2 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Component, OnInit, HostBinding, inject } from '@angular/core';

import { RouterModule } from '@angular/router';
import { Router, NavigationEnd, RouterModule } from '@angular/router';
import { filter } from 'rxjs/operators';
import { HeaderComponent } from './header/header.component';
import { SidebarComponent } from './sidebar/sidebar.component';
import { ToggleButtonComponent } from './toggle-button/toggle-button.component';
import { FooterComponent } from './footer/footer.component';
import { SideBarService } from './services/sidebar.service';
import { AnalyticsService } from './services/analytics/analytics.service';
import { KeycloakService } from './services/keycloak.service';

@Component({
selector: 'app-root',
Expand All @@ -23,7 +26,9 @@ import { SideBarService } from './services/sidebar.service';

export class AppComponent implements OnInit {
private sideBarService = inject(SideBarService);

private analyticsService = inject(AnalyticsService);
private keycloakService = inject(KeycloakService);
private router = inject(Router);

@HostBinding('class.sidebarcontrol')
isOpen = false;
Expand All @@ -32,5 +37,44 @@ export class AppComponent implements OnInit {
this.sideBarService.toggleChange.subscribe(isOpen => {
this.isOpen = isOpen;
});

// Identify user for analytics if authenticated
// This MUST be called before any page tracking to ensure userId is set
if (this.keycloakService.isAuthenticated()) {
const userGuid = this.keycloakService.getUserGuid();
if (userGuid) {
const sessionStart = new Date().toISOString();
this.analyticsService.identify(userGuid, {
username: this.keycloakService.getPreferredUsername(),
roles: this.keycloakService.getUserRoles(),
session_start: sessionStart,
auth_provider: this.keycloakService.getIdpFromToken() || 'unknown'
});
}
}

// Track page views on navigation
// These will only be sent AFTER identify() is called
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe((event: NavigationEnd) => {
const routePath = event.urlAfterRedirects || event.url;
const pageName = this.getPageName(routePath);
this.analyticsService.page(pageName, { path: routePath });
});
}

/** Extract page name from URL path */
private getPageName(path: string): string {
const cleanPath = path.split('?')[0].split('#')[0].split(';')[0];
const routePath = cleanPath.replace(/^\/admin\/?/, '');

if (!routePath || routePath === '/') return 'Home';

return routePath
.split('/')
.filter(s => s && !s.match(/^[0-9a-f-]{20,}$/i)) // Remove IDs
.map(s => s.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
.join(' > ') || 'Home';
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<tr *ngFor="let item of items" (click)="itemClicked(item)">
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[0].width">{{item.type || '-'}}</td>
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[1].width">{{item.appliedTo || '-'}}</td>
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[2].width">{{item.start | date: 'longDate'}}</td>
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[3].width">{{item.end | date: 'longDate'}}</td>
</tr>
@for (item of items; track item) {
<tr (click)="itemClicked(item)">
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[0].width">{{item.type || '-'}}</td>
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[1].width">{{item.appliedTo || '-'}}</td>
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[2].width">{{item.start | date: 'longDate'}}</td>
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[3].width">{{item.end | date: 'longDate'}}</td>
</tr>
}
96 changes: 96 additions & 0 deletions src/app/services/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Injectable } from '@angular/core';
import Analytics from 'analytics';
import type { AnalyticsInstance } from 'analytics';
import { penguinAnalyticsPlugin } from './penguin-analytics-plugin';

interface EnvConfig {
ANALYTICS_API_URL?: string;
ANALYTICS_DEBUG?: boolean;
API_LOCATION?: string;
API_PATH?: string;
}

declare const window: Window & { __env?: EnvConfig };

const buildDefaultAnalyticsUrl = (env?: EnvConfig): string => {
const base = env?.API_LOCATION?.replace(/\/$/, '') || '';
const apiPath = env?.API_PATH || '/api';
const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
return base ? `${base}${normalizedPath}/telemetry` : `${normalizedPath}/telemetry`;
};

/**
* Analytics service using Analytics.io with Penguin Analytics plugin.
*
* ## Auto-tracked events (no code needed):
* - Page views (on route changes)
* - Link clicks
* - Button clicks
* - User activity pings
*
* ## Manual tracking:
* Use "Object + Past Verb" naming: "Form Submitted", "Document Downloaded"
*
* @example
* ```typescript
* // Page view (auto-tracked, but can override)
* analytics.page('Project Details', { project_id: '123' });
*
* // Custom event
* analytics.track('Report Generated', { format: 'pdf' });
*
* // Identify user after login
* analytics.identify(userId, { username: 'john', roles: ['admin'] });
* ```
*/
@Injectable({
providedIn: 'root'
})
export class AnalyticsService {
private analytics: AnalyticsInstance;
private initialized = false;

constructor() {
const env = window.__env;
const apiUrl = env?.ANALYTICS_API_URL || buildDefaultAnalyticsUrl(env);
const debug = env?.ANALYTICS_DEBUG ?? false;

this.analytics = Analytics({
app: 'eagle-admin',
debug: debug,
plugins: [
penguinAnalyticsPlugin({
apiUrl: apiUrl,
sourceApp: 'eagle-admin',
debug: debug
})
]
});

this.initialized = true;
}

/** Track a page view */
page(name?: string, properties?: Record<string, any>): void {
if (!this.initialized) return;
this.analytics.page({ name, ...properties });
}

/** Track a custom event. Use "Object + Past Verb" naming. */
track(event: string, properties?: Record<string, any>): void {
if (!this.initialized) return;
this.analytics.track(event, properties);
}

/** Identify user after authentication */
identify(userId: string, traits?: Record<string, any>): void {
if (!this.initialized) return;
this.analytics.identify(userId, traits);
}

/** Reset on logout */
reset(): void {
if (!this.initialized) return;
this.analytics.reset();
}
}
Loading