From b9de39f95a6c7417ff19093d79a1164ee0ed3a8f Mon Sep 17 00:00:00 2001 From: Giddh's Black Tiger Date: Tue, 14 Apr 2026 18:45:37 +0530 Subject: [PATCH 01/13] refactor: remove static build documentation and update UI text - Delete QUICK-START-STATIC-BUILDS.md and STATIC-BUILD-STRATEGY.md documentation files - Remove arrow icon from "Leave" button text in user profile organization list - Change platform features grid from 4 columns to 3 columns max on large screens --- QUICK-START-STATIC-BUILDS.md | 117 ------- STATIC-BUILD-STRATEGY.md | 319 ------------------ .../user-profile/user-profile.component.html | 2 +- .../src/app/website/home/home.component.ts | 2 +- 4 files changed, 2 insertions(+), 438 deletions(-) delete mode 100644 QUICK-START-STATIC-BUILDS.md delete mode 100644 STATIC-BUILD-STRATEGY.md diff --git a/QUICK-START-STATIC-BUILDS.md b/QUICK-START-STATIC-BUILDS.md deleted file mode 100644 index 5ea10e54..00000000 --- a/QUICK-START-STATIC-BUILDS.md +++ /dev/null @@ -1,117 +0,0 @@ -# Quick Start: Static Build Strategy - -## ๐Ÿš€ Quick Commands - -### Build for Production -```bash -npm run build:proxy-auth:prod -``` -Creates: -- `proxy-auth.js` โ†’ Static from `stable-builds/prod/` -- `proxy-auth-new.js` โ†’ Latest build - -### Build for Test -```bash -npm run build:proxy-auth:test -``` -Creates: -- `proxy-auth.js` โ†’ Static from `stable-builds/test/` -- `proxy-auth-new.js` โ†’ Latest build - -### Build for Stage -```bash -npm run build:proxy-auth:stage -``` -Creates: -- `proxy-auth.js` โ†’ Static from `stable-builds/test/` -- `proxy-auth-new.js` โ†’ Latest build - -## ๐Ÿ“ Where Files Are Created - -Both locations get the same files: - -1. **Source assets**: `apps/36-blocks/src/assets/proxy-auth/` -2. **Dist output**: `dist/apps/36-blocks/browser/assets/proxy-auth/` - -## ๐Ÿงช Testing New Builds - -### Step 1: Build -```bash -npm run build:proxy-auth:test -``` - -### Step 2: Test with proxy-auth-new.js -```html - -``` - -### Step 3: If tests pass, promote to stable -```bash -# Copy tested build to stable -cp dist/apps/36-blocks/browser/assets/proxy-auth/proxy-auth-new.js \ - stable-builds/prod/proxy-auth.js - -# Rebuild to update deployments -npm run build:proxy-auth:prod -``` - -## ๐Ÿ”„ Promoting Builds Checklist - -Before updating stable builds: - -- [ ] New build tested in test environment -- [ ] No console errors -- [ ] Authentication flows work -- [ ] Social logins functional -- [ ] Mobile responsiveness verified -- [ ] Dark mode working - -Then: - -```bash -# Update stable files -cp dist/apps/36-blocks/browser/assets/proxy-auth/proxy-auth-new.js stable-builds/prod/proxy-auth.js -cp dist/apps/36-blocks/browser/assets/proxy-auth/proxy-auth-new.js stable-builds/test/proxy-auth.js - -# Commit -git add stable-builds/ -git commit -m "chore: promote tested build to stable" - -# Rebuild -npm run build:prod -``` - -## ๐Ÿ“‹ Current URLs - -### Production -- **Stable**: `https://proxy.msg91.com/assets/proxy-auth/proxy-auth.js` -- **Testing**: `https://proxy.msg91.com/assets/proxy-auth/proxy-auth-new.js` - -### Test -- **Stable**: `https://test.proxy.msg91.com/assets/proxy-auth/proxy-auth.js` -- **Testing**: `https://test.proxy.msg91.com/assets/proxy-auth/proxy-auth-new.js` - -## โš ๏ธ Important Notes - -1. **Never deploy without testing** - Always test `proxy-auth-new.js` first -2. **Keep stable-builds in git** - These are your production safety net -3. **Check file sizes** - Build warns if >3 MB -4. **Document changes** - Update changelog when promoting builds - -## ๐Ÿ“– Full Documentation - -See `STATIC-BUILD-STRATEGY.md` for: -- Complete architecture details -- Revert instructions -- Troubleshooting guide -- Change history - ---- - -**Quick Help**: If you just want to build normally, use: -```bash -npm run build:proxy-auth:prod # Production -npm run build:proxy-auth:test # Test -``` - -The static file strategy works automatically! ๐ŸŽ‰ diff --git a/STATIC-BUILD-STRATEGY.md b/STATIC-BUILD-STRATEGY.md deleted file mode 100644 index 5ef653d2..00000000 --- a/STATIC-BUILD-STRATEGY.md +++ /dev/null @@ -1,319 +0,0 @@ -# Static Build Strategy for proxy-auth.js - -## ๐Ÿ“‹ Overview - -This document explains the static file strategy implemented for the 36Blocks authentication widget (`proxy-auth.js`). This strategy ensures **backward compatibility** for existing clients while allowing safe testing of new builds. - -## ๐ŸŽฏ Problem Statement - -Production clients depend on stable URLs: -- **Production**: `https://proxy.msg91.com/assets/proxy-auth/proxy-auth.js` -- **Test**: `https://test.proxy.msg91.com/assets/proxy-auth/proxy-auth.js` - -Direct deployment of new builds could break existing client integrations. We needed a way to: -1. Keep stable, tested versions available at these URLs -2. Test new builds safely before rolling them out -3. Provide an easy rollback mechanism - -## ๐Ÿ—๏ธ Solution Architecture - -### File Structure - -``` -proxy-ui/ -โ”œโ”€โ”€ stable-builds/ -โ”‚ โ”œโ”€โ”€ prod/ -โ”‚ โ”‚ โ””โ”€โ”€ proxy-auth.js # Stable production build -โ”‚ โ””โ”€โ”€ test/ -โ”‚ โ””โ”€โ”€ proxy-auth.js # Stable test build -โ”‚ -โ”œโ”€โ”€ apps/36-blocks-widget/ -โ”‚ โ””โ”€โ”€ build-elements.js # Modified build script -โ”‚ -โ””โ”€โ”€ dist/apps/36-blocks/browser/assets/proxy-auth/ - โ”œโ”€โ”€ proxy-auth.js # STATIC (copied from stable-builds) - โ””โ”€โ”€ proxy-auth-new.js # LATEST BUILD (for testing) -``` - -### Build Process - -When you run the build, the script now: - -1. **Builds the widget** from source code -2. **Creates TWO files**: - - `proxy-auth-new.js` โ†’ Latest build from source (for testing) - - `proxy-auth.js` โ†’ Static copy from `stable-builds/{env}/` (for production clients) - -### Environment Mapping - -| Build Config | Stable Source | Output | -|-------------|---------------|--------| -| `production` | `stable-builds/prod/proxy-auth.js` | Production deployment | -| `test` | `stable-builds/test/proxy-auth.js` | Test deployment | -| `stage` | `stable-builds/test/proxy-auth.js` | Staging deployment | - -## ๐Ÿš€ Usage - -### Building for Production - -```bash -# Build widget -nx build 36-blocks-widget --configuration=production - -# Run build-elements script (automatically uses 'production' env) -node apps/36-blocks-widget/build-elements.js production -``` - -### Building for Test/Stage - -```bash -# Build widget -nx build 36-blocks-widget --configuration=test - -# Run build-elements script with test environment -node apps/36-blocks-widget/build-elements.js test -``` - -### Testing New Builds - -To test a new build before promoting it to stable: - -1. Build the widget -2. Use `proxy-auth-new.js` in your test environment: - ```html - - ``` -3. Verify functionality -4. If successful, promote the build (see below) - -### Promoting a New Build to Stable - -When a new build is thoroughly tested and ready for production: - -```bash -# 1. Copy the tested build to stable-builds -cp dist/apps/36-blocks/browser/assets/proxy-auth/proxy-auth-new.js \ - stable-builds/prod/proxy-auth.js - -# 2. For test environment -cp dist/apps/36-blocks/browser/assets/proxy-auth/proxy-auth-new.js \ - stable-builds/test/proxy-auth.js - -# 3. Rebuild to update the deployed proxy-auth.js -node apps/36-blocks-widget/build-elements.js production -``` - -**Alternative**: Update stable files manually after verifying in production. - -## ๐Ÿ”„ How to Revert to Direct Build (Old Behavior) - -If you need to remove the static file strategy and go back to direct builds: - -### Step 1: Restore Original build-elements.js - -Replace the current `build-elements.js` with this simplified version: - -```javascript -const fs = require('fs-extra'); -const path = require('path'); - -(async function build() { - const distDir = './dist/apps/36-blocks-widget/browser'; - const outDir = './apps/36-blocks/src/assets/proxy-auth'; - - if (!(await fs.pathExists(distDir))) { - throw new Error(`Widget dist not found: ${distDir}`); - } - - const allFiles = await fs.readdir(distDir); - const priority = ['polyfills', 'vendor', 'main']; - const jsFiles = allFiles - .filter((f) => f.endsWith('.js')) - .sort((a, b) => { - const getPriority = (f) => { - const index = priority.findIndex((p) => f.includes(p)); - return index === -1 ? priority.length : index; - }; - return getPriority(a) - getPriority(b); - }); - - if (jsFiles.length === 0) { - throw new Error(`No JS files found in ${distDir}`); - } - - console.info('Concatenating:', jsFiles); - - const contents = []; - for (const file of jsFiles) { - contents.push(await fs.readFile(path.join(distDir, file), 'utf8')); - } - - const stylesPath = path.join(distDir, 'styles.css'); - if (await fs.pathExists(stylesPath)) { - console.info('Inlining styles.css...'); - const cssContent = await fs.readFile(stylesPath, 'utf8'); - const escapedCSS = cssContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$'); - - const styleInjector = ` -(function() { - if (typeof window === 'undefined' || !window.document) return; - if (document.getElementById('proxy-auth-widget-styles')) return; - - var style = document.createElement('style'); - style.id = 'proxy-auth-widget-styles'; - style.textContent = \`${escapedCSS}\`; - - if (!window.__proxyAuth) window.__proxyAuth = {}; - window.__proxyAuth.inlinedStyles = \`${escapedCSS}\`; - - (document.head || document.getElementsByTagName('head')[0]).appendChild(style); -})(); -`; - contents.push(styleInjector); - } else { - console.warn('styles.css not found - skipping CSS inlining'); - } - - await fs.ensureDir(outDir); - const outPath = path.join(outDir, 'proxy-auth.js'); - await fs.writeFile(outPath, contents.join('\n')); - - const distOutDir = './dist/apps/36-blocks/browser/assets/proxy-auth'; - await fs.ensureDir(distOutDir); - await fs.copyFile(outPath, path.join(distOutDir, 'proxy-auth.js')); - - const stats = await fs.stat(outPath); - const sizeMB = (stats.size / 1048576).toFixed(2); - console.info(`proxy-auth.js created: ${sizeMB} MB`); - console.info(`Copied to: ${distOutDir}/proxy-auth.js`); - if (stats.size > 3 * 1048576) { - console.warn('WARNING: proxy-auth.js exceeds 3 MB โ€” check for bundle bloat!'); - } - - console.info('Elements created successfully!'); -})(); -``` - -### Step 2: Remove Static Files (Optional) - -```bash -# Remove stable-builds directory if no longer needed -rm -rf stable-builds/ -``` - -### Step 3: Clean Build - -```bash -# Clean and rebuild -rm -rf dist/ -nx build 36-blocks-widget --configuration=production -node apps/36-blocks-widget/build-elements.js -``` - -## ๐Ÿ“Š File Sizes Reference - -Current stable builds: -- **Production** (`stable-builds/prod/proxy-auth.js`): ~1.5 MB -- **Test** (`stable-builds/test/proxy-auth.js`): ~1.7 MB - -## โš ๏ธ Important Notes - -1. **Never delete `stable-builds/` directory** โ€” it contains production-critical files -2. **Always test `proxy-auth-new.js`** before promoting to stable -3. **Keep `stable-builds/` in version control** โ€” these are your rollback point -4. **Monitor bundle sizes** โ€” the script warns if builds exceed 3 MB -5. **Environment detection** โ€” script uses command line argument or defaults to 'production' - -## ๐Ÿ” Verification - -After building, verify the output: - -```bash -# Check both files exist -ls -lh dist/apps/36-blocks/browser/assets/proxy-auth/ - -# Should show: -# - proxy-auth.js (static, from stable-builds) -# - proxy-auth-new.js (latest build) -``` - -## ๐Ÿ“ Change Log - -- **2026-04-09**: Initial implementation of static build strategy - - Modified `build-elements.js` to support dual-file output - - Created `stable-builds/` directory structure - - Added environment-aware build process - -## ๐Ÿค Contributing - -When making changes to the build process: - -1. Test with both production and test configurations -2. Verify file sizes remain reasonable (<3 MB) -3. Update this documentation if behavior changes -4. Keep stable-builds in sync with tested releases - -## ๏ฟฝ Troubleshooting - -### Issue: proxy-auth-new.js has wrong credentials - -If `proxy-auth-new.js` is built with TEST credentials on production: - -#### **Step 1: Enable Debug Logging** - -The `tools/set-env.js` script automatically logs debug info when running in CI (AWS Amplify). Look for these lines in AWS build logs: - -``` -[set-env DEBUG] Environment detection: - CI: true - NODE_ENV: production - AWS_BRANCH: production - isCI: true -[set-env DEBUG] AUTH_UI_ENCODE_KEY = 6bd88de3... -[set-env DEBUG] AUTH_UI_IV_KEY = 9df117bc... -``` - -The first 8 characters help you verify which credentials are being read **without exposing secrets**. - -#### **Step 2: Compare Values** - -Check if the preview matches your **production** or **test** credentials: -- Production `AUTH_UI_ENCODE_KEY` starts with: `6bd88de3` โ†’ โœ… Correct -- Test `AUTH_UI_ENCODE_KEY` starts with: `XXXXXXXX` โ†’ โŒ Wrong environment - -#### **Step 3: Common Fixes** - -**If AWS shows wrong values:** -1. Check AWS Amplify environment variables are set correctly -2. Verify the branch-specific env vars use correct suffix (`_PROD` vs `_TEST`) -3. Ensure `.env` file is created before `npm run build:prod` runs - -**If set-env reads wrong values:** -1. Check `.env` file creation in AWS YML -2. Verify `dotenv` is loading the file correctly -3. Check for environment variable conflicts in AWS settings - -#### **Step 4: Manual Verification** - -Add this to AWS YML **after** creating `.env`: - -```yaml -- cat .env # Shows what's in .env file (secrets will be visible in logs!) -``` - -โš ๏ธ **Warning**: This exposes secrets in build logs. Remove after debugging. - -## ๏ฟฝ๐Ÿ“ž Support - -If you encounter issues: - -1. Check the build script output for warnings -2. Verify `stable-builds/{env}/proxy-auth.js` exists -3. Ensure build script receives correct environment argument -4. Review the **debug logs** from `set-env` for credential verification -5. Compare first 8 chars of credentials in logs vs expected values - ---- - -**Last Updated**: April 9, 2026 -**Maintained By**: 36Blocks Development Team \ No newline at end of file diff --git a/apps/36-blocks-widget/src/app/otp/user-profile/user-profile.component.html b/apps/36-blocks-widget/src/app/otp/user-profile/user-profile.component.html index f81f7434..c4000eb4 100644 --- a/apps/36-blocks-widget/src/app/otp/user-profile/user-profile.component.html +++ b/apps/36-blocks-widget/src/app/otp/user-profile/user-profile.component.html @@ -257,7 +257,7 @@

Organizations - โ†— Leave + Leave } diff --git a/apps/36-blocks/src/app/website/home/home.component.ts b/apps/36-blocks/src/app/website/home/home.component.ts index 472bffd5..5d152c96 100644 --- a/apps/36-blocks/src/app/website/home/home.component.ts +++ b/apps/36-blocks/src/app/website/home/home.component.ts @@ -204,7 +204,7 @@ export class HomeComponent extends BaseComponent implements OnInit { label: 'Everything You Need to Manage Users', accentWord: 'Securely', desc: 'Replace months of custom authentication development with production-ready components built for security, scale, and speed.', - gridCols: 'grid-cols-1 md:grid-cols-3 sm:grid-cols-2 lg:grid-cols-4', + gridCols: 'grid-cols-1 md:grid-cols-3 sm:grid-cols-2', cards: this.platformFeatures, }, { From 2284b37feed4cfe36577457afe5d229a44896bb7 Mon Sep 17 00:00:00 2001 From: Giddh's Black Tiger Date: Wed, 15 Apr 2026 11:51:48 +0530 Subject: [PATCH 02/13] refactor: update domain references from proxy.msg91.com to 36blocks.com - Update widget powered-by footer link from proxy.msg91.com to 36blocks.com - Change baseUrl in widget stage environment from proxy.msg91.com to 36blocks.com - Update powered-by footer link in create-feature component template - Change code example script src URL in home component from proxy.msg91.com to 36blocks.com - Update proxyServer in main app stage environment from proxy.msg91.com to 36blocks.com --- apps/36-blocks-widget/src/app/otp/widget/widget.component.ts | 2 +- apps/36-blocks-widget/src/environments/environment.stage.ts | 2 +- .../panel/features/create-feature/create-feature.component.html | 2 +- apps/36-blocks/src/app/website/home/home.component.ts | 2 +- apps/36-blocks/src/environments/environment.stage.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/36-blocks-widget/src/app/otp/widget/widget.component.ts b/apps/36-blocks-widget/src/app/otp/widget/widget.component.ts index 349592d3..e9c5be35 100644 --- a/apps/36-blocks-widget/src/app/otp/widget/widget.component.ts +++ b/apps/36-blocks-widget/src/app/otp/widget/widget.component.ts @@ -1498,7 +1498,7 @@ export class ProxyAuthWidgetComponent extends BaseComponent implements OnInit, O // Powered by footer const poweredBy: HTMLParagraphElement = this.renderer.createElement('a'); - poweredBy.setAttribute('href', 'https://proxy.msg91.com'); + poweredBy.setAttribute('href', 'https://36blocks.com'); poweredBy.setAttribute('target', '_blank'); poweredBy.setAttribute('rel', 'noopener noreferrer'); poweredBy.setAttribute('data-powered-by', 'true'); diff --git a/apps/36-blocks-widget/src/environments/environment.stage.ts b/apps/36-blocks-widget/src/environments/environment.stage.ts index 8426ed43..a5261f30 100644 --- a/apps/36-blocks-widget/src/environments/environment.stage.ts +++ b/apps/36-blocks-widget/src/environments/environment.stage.ts @@ -4,7 +4,7 @@ export const environment = { production: true, env: 'prod', apiUrl: 'https://routes.msg91.com/api', - baseUrl: 'https://proxy.msg91.com', + baseUrl: 'https://36blocks.com', msgMidProxy: '', ...envVariables, }; diff --git a/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.html b/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.html index 9e401c5f..61f97e62 100644 --- a/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.html +++ b/apps/36-blocks/src/app/panel/features/create-feature/create-feature.component.html @@ -907,7 +907,7 @@

src="https://proxy.msg91.com/...">`, + html: ` src="https://36blocks.com/...">`, }, { num: '19', html: ` </script>` }, ]); diff --git a/apps/36-blocks/src/environments/environment.stage.ts b/apps/36-blocks/src/environments/environment.stage.ts index 639b3d93..a8d84b1b 100644 --- a/apps/36-blocks/src/environments/environment.stage.ts +++ b/apps/36-blocks/src/environments/environment.stage.ts @@ -7,7 +7,7 @@ import { envVariables } from './env-variables'; export const environment = { production: false, env: 'dev', - proxyServer: 'https://proxy.msg91.com', + proxyServer: 'https://36blocks.com', baseUrl: 'https://routes.msg91.com/api', ...envVariables, }; From 78facc08f598561bb937feb264ed148f80cdb627 Mon Sep 17 00:00:00 2001 From: Saurabh186 Date: Wed, 15 Apr 2026 12:52:41 +0530 Subject: [PATCH 03/13] P0082 | Dashboard Integration & install Apache ECharts --- .../breakdown-chart.component.html | 62 +++++ .../breakdown-chart.component.ts | 179 ++++++++++++++ .../breakdown-expand-dialog.component.ts | 74 ++++++ .../panel/dashboard/dashboard.component.html | 103 +++++--- .../panel/dashboard/dashboard.component.scss | 78 ------ .../panel/dashboard/dashboard.component.ts | 159 +++++++++++- .../chart-expand-dialog.component.ts | 88 +++++++ .../timeseries-chart.component.html | 67 +++++ .../timeseries-chart.component.ts | 232 ++++++++++++++++++ libs/constant/src/index.ts | 1 + libs/constant/src/info-tooltips.ts | 61 +++++ libs/services/proxy/analytics/src/index.ts | 1 + .../analytics/src/lib/analytics.service.ts | 47 ++++ libs/urls/analytics-urls/src/index.ts | 8 + package-lock.json | 32 +++ package.json | 1 + tsconfig.base.json | 2 + 17 files changed, 1086 insertions(+), 109 deletions(-) create mode 100644 apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.html create mode 100644 apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts create mode 100644 apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-expand-dialog.component.ts create mode 100644 apps/36-blocks/src/app/panel/dashboard/timeseries-chart/chart-expand-dialog.component.ts create mode 100644 apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.html create mode 100644 apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.ts create mode 100644 libs/constant/src/info-tooltips.ts create mode 100644 libs/services/proxy/analytics/src/index.ts create mode 100644 libs/services/proxy/analytics/src/lib/analytics.service.ts create mode 100644 libs/urls/analytics-urls/src/index.ts diff --git a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.html b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.html new file mode 100644 index 00000000..bd0e5e16 --- /dev/null +++ b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.html @@ -0,0 +1,62 @@ +
+ +
+
+ Breakdown + info_outline +
+ +
+ + + @if (isLoading()) { +
+
+
+ } + + + @if (error()) { +
+ error_outline + {{ error() }} +
+ } + + + @if (!isLoading() && !error()) { + @if (chartData().length) { +
+
+ + + @for (opt of groupByOptions; track opt.value) { + {{ opt.label }} + } + + +
+ } @else { +
+ pie_chart + No data for this period +
+
+ + + @for (opt of groupByOptions; track opt.value) { + {{ opt.label }} + } + + +
+ } + } +
diff --git a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts new file mode 100644 index 00000000..e099aef0 --- /dev/null +++ b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts @@ -0,0 +1,179 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + ViewChild, + effect, + inject, + input, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { FormsModule } from '@angular/forms'; +import { BaseComponent } from '@proxy/ui/base-component'; +import { AnalyticsService, IBreakdownParams } from '@proxy/services/proxy/analytics'; +import { takeUntil } from 'rxjs'; +import * as echarts from 'echarts'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDialog } from '@angular/material/dialog'; +import { BreakdownExpandDialogComponent } from './breakdown-expand-dialog.component'; +import { INFO_TOOLTIPS } from '@proxy/constant'; + +export type BreakdownGroupBy = 'service_id' | 'source' | 'type'; + +@Component({ + selector: 'proxy-breakdown-chart', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatIconModule, + MatSelectModule, + MatFormFieldModule, + FormsModule, + MatButtonModule, + MatTooltipModule, + ], + templateUrl: './breakdown-chart.component.html', +}) +export class BreakdownChartComponent extends BaseComponent implements OnDestroy { + readonly infoTooltip = INFO_TOOLTIPS.dashboard.charts.breakdown; + @ViewChild('chartEl', { static: false }) chartEl!: ElementRef; + + range = input('week'); + featureConfigurationId = input(null); + + private analyticsService = inject(AnalyticsService); + private dialog = inject(MatDialog); + private chart: echarts.ECharts | null = null; + private themeObserver: MutationObserver | null = null; + + readonly isLoading = signal(false); + readonly error = signal(null); + readonly chartData = signal<{ key: string; count: number }[]>([]); + + selectedGroupBy = signal('type'); + + readonly groupByOptions: { label: string; value: BreakdownGroupBy }[] = [ + { label: 'By Type', value: 'type' }, + // { label: 'By Source', value: 'source' }, + { label: 'By Service', value: 'service_id' }, + ]; + + readonly palette = ['#6366f1', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444']; + + constructor() { + super(); + this.watchTheme(); + effect(() => { + const params: IBreakdownParams = { + range: this.range() as any, + group_by: this.selectedGroupBy(), + }; + const fcId = this.featureConfigurationId(); + if (fcId) params.feature_configuration_id = fcId; + this.fetchBreakdown(params); + }); + } + + fetchBreakdown(params: IBreakdownParams): void { + this.isLoading.set(true); + this.error.set(null); + this.chartData.set([]); + + this.analyticsService + .getBreakdown(params) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (res: any) => { + const data = res?.data?.data ?? []; + this.chartData.set(data); + this.isLoading.set(false); + setTimeout(() => this.renderChart(data), 0); + }, + error: (err: any) => { + this.error.set(err?.message ?? 'Failed to load breakdown'); + this.isLoading.set(false); + }, + }); + } + + onGroupByChange(value: BreakdownGroupBy): void { + this.selectedGroupBy.set(value); + } + + openExpand(): void { + this.dialog.open(BreakdownExpandDialogComponent, { + data: { + data: this.chartData(), + palette: this.palette, + label: + 'Breakdown โ€” ' + (this.groupByOptions.find((o) => o.value === this.selectedGroupBy())?.label ?? ''), + }, + width: '80vw', + maxWidth: '900px', + }); + } + + private getCssVar(name: string): string { + return getComputedStyle(this.chartEl.nativeElement).getPropertyValue(name).trim(); + } + + private watchTheme(): void { + this.themeObserver?.disconnect(); + this.themeObserver = new MutationObserver(() => { + if (this.chartData().length) this.renderChart(this.chartData()); + }); + this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + } + + private renderChart(data: { key: string; count: number }[]): void { + if (!this.chartEl?.nativeElement) return; + if (this.chart) { + this.chart.dispose(); + } + + const borderColor = this.getCssVar('--color-common-border'); + const textColor = this.getCssVar('--color-common-text'); + const bgColor = this.getCssVar('--color-common-bg'); + + this.chart = echarts.init(this.chartEl.nativeElement); + this.chart.setOption({ + backgroundColor: 'transparent', + tooltip: { + trigger: 'item', + formatter: '{b}: {c} ({d}%)', + backgroundColor: bgColor, + borderColor: borderColor, + textStyle: { color: textColor }, + }, + legend: { bottom: 0, left: 'center', textStyle: { fontSize: 11, color: textColor } }, + series: [ + { + type: 'pie', + radius: ['40%', '70%'], + center: ['50%', '45%'], + data: data.map((d, i) => ({ + name: d.key, + value: d.count, + itemStyle: { color: this.palette[i % this.palette.length] }, + })), + label: { show: false }, + emphasis: { + itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.2)' }, + }, + }, + ], + }); + } + + override ngOnDestroy(): void { + this.themeObserver?.disconnect(); + this.chart?.dispose(); + super.ngOnDestroy(); + } +} diff --git a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-expand-dialog.component.ts b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-expand-dialog.component.ts new file mode 100644 index 00000000..f87f75ae --- /dev/null +++ b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-expand-dialog.component.ts @@ -0,0 +1,74 @@ +import { Component, ElementRef, Inject, OnDestroy, ViewChild, AfterViewInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import * as echarts from 'echarts'; + +export interface BreakdownDialogData { + data: { key: string; count: number }[]; + palette: string[]; + label: string; +} + +@Component({ + selector: 'proxy-breakdown-expand-dialog', + standalone: true, + imports: [MatDialogModule, MatButtonModule, MatIconModule], + template: ` +
+ {{ data.label }} + +
+ +
+
+ `, +}) +export class BreakdownExpandDialogComponent implements AfterViewInit, OnDestroy { + @ViewChild('chartEl', { static: false }) chartEl!: ElementRef; + private chart: echarts.ECharts | null = null; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: BreakdownDialogData + ) {} + + ngAfterViewInit(): void { + setTimeout(() => this.renderChart(), 0); + } + + private renderChart(): void { + if (!this.chartEl?.nativeElement) return; + this.chart = echarts.init(this.chartEl.nativeElement); + this.chart.setOption({ + tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, + legend: { bottom: 0, left: 'center', textStyle: { fontSize: 12 } }, + series: [ + { + type: 'pie', + radius: ['38%', '65%'], + center: ['50%', '45%'], + data: this.data.data.map((d, i) => ({ + name: d.key, + value: d.count, + itemStyle: { color: this.data.palette[i % this.data.palette.length] }, + })), + label: { show: true, formatter: '{b}\n{d}%', fontSize: 12 }, + emphasis: { + itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.2)' }, + }, + }, + ], + }); + } + + close(): void { + this.dialogRef.close(); + } + + ngOnDestroy(): void { + this.chart?.dispose(); + } +} diff --git a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html index cd3446a2..8f4b40a5 100644 --- a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html +++ b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html @@ -1,32 +1,79 @@ -
-
-
- rocket_launch +
+ +
+

Analytics Overview

+
+ + + Client + + @for (f of features(); track f.id) { + {{ f.name }} + } + + + + + + @for (opt of rangeOptions; track opt.value) { + {{ opt.label }} + } + +
-

Coming Soon

-

We're working hard to bring you something amazing.

-

- Our dashboard is under construction. Stay tuned for powerful analytics and insights. -

-
-
- analytics - Analytics -
-
- insights - Insights -
-
- trending_up - Reports -
+
+ + + @if (overviewError()) { +
+ error_outline + {{ overviewError() }}
+ } + + +
+ @if (isLoadingOverview()) { + @for (i of [1, 2, 3, 4]; track i) { +
+
+
+
+
+ } + } @else { + @for (card of overviewCards; track card.key) { +
+
+ + {{ card.icon }} + + {{ card.label }} + info_outline +
+

+ {{ getCardValue(overviewData(), card.valueKey) | number }} +

+ {{ card.sub }} +
+ } + } +
+ + +
+ +
diff --git a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.scss b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.scss index 08e76427..e69de29b 100644 --- a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.scss +++ b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.scss @@ -1,78 +0,0 @@ -:host { - display: flex; - width: 100%; - height: 100%; -} - -.coming-soon-content { - animation: fadeInUp 0.8s ease-out; -} - -.icon-wrapper { - background: linear-gradient(135deg, var(--color-dark-accent) 0%, #15b8a0 100%); - animation: pulse 2s infinite; -} - -.coming-soon-icon { - font-size: 56px !important; - width: 56px !important; - height: 56px !important; - color: var(--color-common-white); -} - -.coming-soon-title { - color: var(--color-common-text); -} - -.coming-soon-subtitle { - color: var(--color-dark-accent); -} - -.coming-soon-description { - color: var(--color-common-placeholder); -} - -.feature-item { - background: var(--color-common-bg); - border-color: var(--color-common-border); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - - &:hover { - background: var(--color-dark-accent-light); - border-color: var(--color-dark-accent); - transform: translateY(-4px); - box-shadow: 0 10px 20px -5px rgba(25, 230, 206, 0.2); - } - - .feature-icon { - font-size: 32px !important; - width: 32px !important; - height: 32px !important; - color: var(--color-dark-accent); - } - - span { - color: var(--color-common-text); - } -} - -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(30px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes pulse { - 0%, - 100% { - box-shadow: 0 0 0 0 rgba(25, 230, 206, 0.4); - } - 50% { - box-shadow: 0 0 0 20px rgba(25, 230, 206, 0); - } -} diff --git a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts index 7bd41d24..c80f809d 100644 --- a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts +++ b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts @@ -1,13 +1,166 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, computed, effect, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { FormsModule } from '@angular/forms'; import { BaseComponent } from '@proxy/ui/base-component'; +import { AnalyticsService, IAnalyticsParams } from '@proxy/services/proxy/analytics'; +import { FeaturesService } from '@proxy/services/proxy/features'; +import { IFeature } from '@proxy/models/features-model'; +import { TimeseriesChartComponent } from './timeseries-chart/timeseries-chart.component'; +import { BreakdownChartComponent } from './breakdown-chart/breakdown-chart.component'; +import { INFO_TOOLTIPS } from '@proxy/constant'; +import { takeUntil } from 'rxjs'; + +export type DateRange = 'day' | 'week' | 'month'; // dashboard @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'proxy-dashboard', - imports: [RouterModule, MatIconModule], + imports: [ + CommonModule, + RouterModule, + MatIconModule, + MatSelectModule, + MatFormFieldModule, + MatButtonModule, + MatTooltipModule, + FormsModule, + TimeseriesChartComponent, + BreakdownChartComponent, + ], templateUrl: './dashboard.component.html', styleUrl: './dashboard.component.scss', }) -export class DashboardComponent extends BaseComponent {} +export class DashboardComponent extends BaseComponent implements OnInit { + private analyticsService = inject(AnalyticsService); + private featuresService = inject(FeaturesService); + + readonly features = signal([]); + readonly isLoadingFeatures = signal(false); + + readonly range = signal('week'); + readonly featureConfigurationId = signal(''); + + readonly overviewData = signal(null); + readonly isLoadingOverview = signal(false); + readonly overviewError = signal(null); + + readonly params = computed(() => { + const base: IAnalyticsParams = { range: this.range() }; + const fcId = this.featureConfigurationId(); + if (fcId !== '' && fcId !== null) base.feature_configuration_id = fcId as number; + return base; + }); + + readonly overviewCards = [ + { + key: 'signups', + valueKey: 'signups.total', + label: 'Signups', + icon: 'person_add', + bgClass: 'bg-indigo-100 dark:bg-indigo-900/40', + iconClass: 'text-indigo-500', + sub: 'in selected period', + infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.signups, + }, + { + key: 'logins', + valueKey: 'logins.total', + label: 'Logins', + icon: 'login', + bgClass: 'bg-violet-100 dark:bg-violet-900/40', + iconClass: 'text-violet-500', + sub: 'in selected period', + infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.logins, + }, + { + key: 'active_users', + valueKey: 'active_users.unique', + label: 'Active Users', + icon: 'group', + bgClass: 'bg-cyan-100 dark:bg-cyan-900/40', + iconClass: 'text-cyan-500', + sub: 'unique in period', + infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.active_users, + }, + { + key: 'users', + valueKey: 'users.client_total', + label: 'Total Users', + icon: 'people', + bgClass: 'bg-emerald-100 dark:bg-emerald-900/40', + iconClass: 'text-emerald-500', + sub: 'registered total', + infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.users, + }, + ]; + + getCardValue(data: any, path: string): number { + return path.split('.').reduce((acc, k) => acc?.[k], data) ?? 0; + } + + readonly rangeOptions: { label: string; value: DateRange }[] = [ + { label: 'Today', value: 'day' }, + { label: 'This Week', value: 'week' }, + { label: 'This Month', value: 'month' }, + ]; + + constructor() { + super(); + effect(() => { + const p = this.params(); + this.fetchOverview(p); + }); + } + + ngOnInit(): void { + this.fetchFeatures(); + } + + fetchFeatures(): void { + this.isLoadingFeatures.set(true); + this.featuresService + .getFeature({}) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (res: any) => { + this.features.set(res?.data?.data ?? []); + this.isLoadingFeatures.set(false); + }, + error: () => this.isLoadingFeatures.set(false), + }); + } + + onFeatureChange(id: number | null): void { + this.featureConfigurationId.set(id); + } + + fetchOverview(params: IAnalyticsParams): void { + this.isLoadingOverview.set(true); + this.overviewData.set(null); + this.overviewError.set(null); + + this.analyticsService + .getOverview(params) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (res: any) => { + this.overviewData.set(res?.data ?? null); + this.isLoadingOverview.set(false); + }, + error: (err: any) => { + this.overviewError.set(err?.message ?? 'Failed to load overview'); + this.isLoadingOverview.set(false); + }, + }); + } + + onRangeChange(value: DateRange): void { + this.range.set(value); + } +} diff --git a/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/chart-expand-dialog.component.ts b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/chart-expand-dialog.component.ts new file mode 100644 index 00000000..b2c124d7 --- /dev/null +++ b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/chart-expand-dialog.component.ts @@ -0,0 +1,88 @@ +import { Component, ElementRef, Inject, OnDestroy, ViewChild, AfterViewInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import * as echarts from 'echarts'; + +export interface ChartDialogData { + label: string; + color: string; + data: { period: string; value: number }[]; +} + +@Component({ + selector: 'proxy-chart-expand-dialog', + standalone: true, + imports: [MatDialogModule, MatButtonModule, MatIconModule], + template: ` +
+ {{ data.label }} + +
+ +
+
+ `, +}) +export class ChartExpandDialogComponent implements AfterViewInit, OnDestroy { + @ViewChild('chartEl', { static: false }) chartEl!: ElementRef; + private chart: echarts.ECharts | null = null; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ChartDialogData + ) {} + + ngAfterViewInit(): void { + setTimeout(() => this.renderChart(), 0); + } + + private renderChart(): void { + if (!this.chartEl?.nativeElement) return; + this.chart = echarts.init(this.chartEl.nativeElement); + this.chart.setOption({ + tooltip: { trigger: 'axis' }, + grid: { top: 16, right: 24, bottom: 40, left: 56 }, + xAxis: { + type: 'category', + data: this.data.data.map((d) => d.period), + axisLabel: { fontSize: 11 }, + axisLine: { lineStyle: { color: '#e5e7eb' } }, + splitLine: { show: false }, + }, + yAxis: { + type: 'value', + axisLabel: { fontSize: 11 }, + splitLine: { lineStyle: { color: '#e5e7eb' } }, + }, + series: [ + { + name: this.data.label, + type: 'line', + data: this.data.data.map((d) => d.value), + smooth: true, + symbol: 'circle', + symbolSize: 6, + lineStyle: { width: 2, color: this.data.color }, + itemStyle: { color: this.data.color }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: this.data.color + '33' }, + { offset: 1, color: this.data.color + '05' }, + ]), + }, + }, + ], + }); + } + + close(): void { + this.dialogRef.close(); + } + + ngOnDestroy(): void { + this.chart?.dispose(); + } +} diff --git a/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.html b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.html new file mode 100644 index 00000000..59feb416 --- /dev/null +++ b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.html @@ -0,0 +1,67 @@ + + + +@if (isLoading()) { +
+
+
+
+} + + + + + +@if (isLoading()) { +
+
+
+
+
+
+
+
+} +@if (!isLoading() && seriesList().length) { + @for (s of seriesList(); track s.label) { +
+
+
+ {{ s.label }} + info_outline +
+ +
+
+
+ } +} +@if (!isLoading() && !seriesList().length) { +
+ show_chart + No data for this period +
+} diff --git a/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.ts b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.ts new file mode 100644 index 00000000..4775ac13 --- /dev/null +++ b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.ts @@ -0,0 +1,232 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + QueryList, + ViewChildren, + effect, + inject, + input, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSelectModule } from '@angular/material/select'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { FormsModule } from '@angular/forms'; +import { BaseComponent } from '@proxy/ui/base-component'; +import { AnalyticsService, ITimeseriesParams } from '@proxy/services/proxy/analytics'; +import { forkJoin, takeUntil } from 'rxjs'; +import * as echarts from 'echarts'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDialog } from '@angular/material/dialog'; +import { ChartExpandDialogComponent } from './chart-expand-dialog.component'; +import { INFO_TOOLTIPS } from '@proxy/constant'; + +// chart component +export type TimeseriesMetric = 'logins' | 'signups' | 'active_users'; +export type TimeseriesInterval = 'hour' | 'day' | 'week'; + +@Component({ + selector: 'proxy-timeseries-chart', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { style: 'display: contents' }, + imports: [ + CommonModule, + MatIconModule, + MatSelectModule, + MatFormFieldModule, + FormsModule, + MatCardModule, + MatButtonModule, + MatTooltipModule, + ], + templateUrl: './timeseries-chart.component.html', +}) +export class TimeseriesChartComponent extends BaseComponent implements OnDestroy { + @ViewChildren('chartEl') chartEls!: QueryList>; + + range = input('week'); + featureConfigurationId = input(null); + + private analyticsService = inject(AnalyticsService); + private dialog = inject(MatDialog); + + readonly isLoading = signal(false); + readonly error = signal(null); + readonly seriesList = signal< + { label: string; color: string; infoTooltip: string; data: { period: string; value: number }[] }[] + >([]); + + selectedInterval = signal('day'); + + readonly metrics: { label: string; value: TimeseriesMetric; color: string; infoTooltip: string }[] = [ + { label: 'Logins', value: 'logins', color: '#6366f1', infoTooltip: INFO_TOOLTIPS.dashboard.charts.logins }, + { label: 'Signups', value: 'signups', color: '#10b981', infoTooltip: INFO_TOOLTIPS.dashboard.charts.signups }, + { + label: 'Active Users', + value: 'active_users', + color: '#f59e0b', + infoTooltip: INFO_TOOLTIPS.dashboard.charts.active_users, + }, + ]; + + readonly intervalOptions: { label: string; value: TimeseriesInterval }[] = [ + { label: 'Hourly', value: 'hour' }, + { label: 'Daily', value: 'day' }, + { label: 'Weekly', value: 'week' }, + ]; + + private intervalForRange(range: string): TimeseriesInterval { + if (range === 'day') return 'hour'; + if (range === 'month') return 'week'; + return 'day'; + } + + constructor() { + super(); + effect(() => { + const range = this.range(); + this.selectedInterval.set(this.intervalForRange(range)); + const fcId = this.featureConfigurationId(); + this.fetchAllMetrics(fcId); + }); + this.watchTheme(); + } + + fetchAllMetrics(fcId: number | null): void { + this.isLoading.set(true); + this.error.set(null); + this.seriesList.set([]); + + const calls = this.metrics.map((m) => { + const params: ITimeseriesParams = { + range: this.range() as any, + metric: m.value, + interval: this.selectedInterval(), + }; + if (fcId) params.feature_configuration_id = fcId; + return this.analyticsService.getTimeseries(params); + }); + + forkJoin(calls) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (results: any[]) => { + const list = results.map((res, i) => ({ + label: this.metrics[i].label, + color: this.metrics[i].color, + infoTooltip: this.metrics[i].infoTooltip, + data: (res?.data?.data ?? []) as { period: string; value: number }[], + })); + this.seriesList.set(list); + this.isLoading.set(false); + setTimeout(() => this.renderCharts(), 0); + }, + error: (err: any) => { + this.error.set(err?.message ?? 'Failed to load timeseries'); + this.isLoading.set(false); + }, + }); + } + + private charts: echarts.ECharts[] = []; + private themeObserver: MutationObserver | null = null; + + private getCssVar(el: HTMLElement, name: string): string { + return getComputedStyle(el).getPropertyValue(name).trim(); + } + + private watchTheme(): void { + this.themeObserver?.disconnect(); + this.themeObserver = new MutationObserver(() => this.renderCharts()); + this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + } + + private renderCharts(): void { + this.charts.forEach((c) => c?.dispose()); + this.charts = []; + + const list = this.seriesList(); + list.forEach((s, i) => { + const el = this.chartEls?.get(i)?.nativeElement; + if (!el) return; + + const borderColor = this.getCssVar(el, '--color-common-border'); + const textColor = this.getCssVar(el, '--color-common-text'); + const bgColor = this.getCssVar(el, '--color-common-bg'); + + if (this.charts[i]) this.charts[i].dispose(); + this.charts[i] = echarts.init(el); + this.charts[i].setOption( + { + backgroundColor: 'transparent', + tooltip: { + trigger: 'axis', + backgroundColor: bgColor, + borderColor: borderColor, + textStyle: { color: textColor }, + }, + grid: { top: 8, right: 16, bottom: 36, left: 48 }, + xAxis: { + type: 'category', + data: s.data.map((d) => d.period), + axisLabel: { fontSize: 10, color: textColor }, + axisLine: { lineStyle: { color: borderColor } }, + splitLine: { show: false }, + }, + yAxis: { + type: 'value', + axisLabel: { fontSize: 10, color: textColor }, + splitLine: { lineStyle: { color: borderColor } }, + }, + series: [ + { + name: s.label, + type: 'line', + data: s.data.map((d) => d.value), + smooth: true, + symbol: 'circle', + symbolSize: 5, + lineStyle: { width: 2, color: s.color }, + itemStyle: { color: s.color }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: s.color + '33' }, + { offset: 1, color: s.color + '05' }, + ]), + }, + }, + ], + }, + true + ); + }); + } + + openExpand(s: { + label: string; + color: string; + infoTooltip: string; + data: { period: string; value: number }[]; + }): void { + this.dialog.open(ChartExpandDialogComponent, { + data: s, + width: '80vw', + maxWidth: '1000px', + }); + } + + onIntervalChange(value: TimeseriesInterval): void { + this.selectedInterval.set(value); + } + + override ngOnDestroy(): void { + this.themeObserver?.disconnect(); + this.charts.forEach((c) => c?.dispose()); + super.ngOnDestroy(); + } +} diff --git a/libs/constant/src/index.ts b/libs/constant/src/index.ts index a44e5ced..cecec4b9 100644 --- a/libs/constant/src/index.ts +++ b/libs/constant/src/index.ts @@ -3,6 +3,7 @@ import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; import { cloneDeep } from 'lodash-es'; export * from './drop-down'; +export * from './info-tooltips'; export * from './mat-icon'; export * from './jitsi-calling'; export * from './permission-mapping'; diff --git a/libs/constant/src/info-tooltips.ts b/libs/constant/src/info-tooltips.ts new file mode 100644 index 00000000..4f6be26b --- /dev/null +++ b/libs/constant/src/info-tooltips.ts @@ -0,0 +1,61 @@ +/** + * INFO_TOOLTIPS + * + * Global registry for all info-icon tooltip strings across the panel. + * Organised by page/section. Import from @proxy/constant and bind to + * [matTooltip] on an info_outline element. + * + * Usage in TS: + * import { INFO_TOOLTIPS } from '@proxy/constant'; + * readonly tip = INFO_TOOLTIPS.dashboard.overviewCards.signups; + * + * Usage in HTML (after exposing via a class property or directly): + * + * info_outline + * + */ +export const INFO_TOOLTIPS = { + /** โ”€โ”€โ”€ Analytics Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + dashboard: { + overviewCards: { + signups: 'Total number of new user sign-ups during the selected time period.', + logins: 'Total number of login events recorded during the selected time period.', + active_users: 'Count of unique users who performed at least one action in the selected period.', + users: 'Cumulative total of all registered users on your platform.', + }, + charts: { + logins: 'Daily login trend showing how user login activity changes over time.', + signups: 'Daily sign-up trend showing new user registrations over time.', + active_users: 'Daily active user trend showing unique engaged users over time.', + breakdown: 'Distribution of activity grouped by the selected dimension (type, source, or service).', + }, + }, + + /** โ”€โ”€โ”€ Features / Blocks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + features: { + apiKey: 'Unique key used to authenticate API requests for this feature/block.', + webhookUrl: 'Endpoint that receives real-time event notifications from this block.', + rateLimiting: 'Maximum number of requests allowed per minute for this block.', + authMethods: 'Authentication strategies enabled for this block (OTP, Google, Microsoft, etc.).', + subscriptionPlan: 'Plan that governs the usage quota and billing for this block.', + }, + + /** โ”€โ”€โ”€ Logs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + logs: { + requestId: 'Unique identifier for each individual API request.', + statusCode: 'HTTP response code returned for this request.', + latency: 'Time taken (in ms) to process and respond to the request.', + environment: 'The project environment (e.g. Production, Staging) that handled the request.', + }, + + /** โ”€โ”€โ”€ Users / User Management โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + users: { + totalUsers: 'Total registered users across all environments for this project.', + activeUsers: 'Users who have performed at least one action in the last 30 days.', + blockedUsers: 'Users whose access has been revoked or suspended.', + lastLogin: 'Timestamp of the most recent successful login for this user.', + }, +} as const; + +export type InfoTooltips = typeof INFO_TOOLTIPS; diff --git a/libs/services/proxy/analytics/src/index.ts b/libs/services/proxy/analytics/src/index.ts new file mode 100644 index 00000000..e1facd4d --- /dev/null +++ b/libs/services/proxy/analytics/src/index.ts @@ -0,0 +1 @@ +export * from './lib/analytics.service'; diff --git a/libs/services/proxy/analytics/src/lib/analytics.service.ts b/libs/services/proxy/analytics/src/lib/analytics.service.ts new file mode 100644 index 00000000..df69f7f4 --- /dev/null +++ b/libs/services/proxy/analytics/src/lib/analytics.service.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@angular/core'; +import { HttpWrapperService } from '@proxy/services/httpWrapper'; +import { ProxyBaseUrls } from '@proxy/models/root-models'; +import { Observable } from 'rxjs'; +import { AnalyticsUrls } from '@proxy/urls/analytics-urls'; + +export interface IAnalyticsParams { + feature_configuration_id?: number; + range?: 'day' | 'week' | 'month'; + start?: string; + end?: string; +} + +export interface ITimeseriesParams extends IAnalyticsParams { + metric: 'signups' | 'logins' | 'active_users'; + interval: 'hour' | 'day' | 'week'; +} + +export interface IBreakdownParams extends IAnalyticsParams { + group_by: 'service_id' | 'source' | 'type'; +} + +@Injectable({ + providedIn: 'root', +}) +export class AnalyticsService { + constructor( + private http: HttpWrapperService, + @Inject(ProxyBaseUrls.BaseURL) private baseURL: string + ) {} + + public getOverview(params: IAnalyticsParams): Observable { + return this.http.get(AnalyticsUrls.overview(this.baseURL), params); + } + + public getTimeseries(params: ITimeseriesParams): Observable { + return this.http.get(AnalyticsUrls.timeseries(this.baseURL), params); + } + + public getActiveUsers(params: IAnalyticsParams): Observable { + return this.http.get(AnalyticsUrls.activeUsers(this.baseURL), params); + } + + public getBreakdown(params: IBreakdownParams): Observable { + return this.http.get(AnalyticsUrls.breakdown(this.baseURL), params); + } +} diff --git a/libs/urls/analytics-urls/src/index.ts b/libs/urls/analytics-urls/src/index.ts new file mode 100644 index 00000000..89b9ae74 --- /dev/null +++ b/libs/urls/analytics-urls/src/index.ts @@ -0,0 +1,8 @@ +import { createUrl } from '@proxy/service'; + +export const AnalyticsUrls = { + overview: (baseUrl: string) => createUrl(baseUrl, 'analytics/overview'), + timeseries: (baseUrl: string) => createUrl(baseUrl, 'analytics/timeseries'), + activeUsers: (baseUrl: string) => createUrl(baseUrl, 'analytics/active-users'), + breakdown: (baseUrl: string) => createUrl(baseUrl, 'analytics/breakdown'), +}; diff --git a/package-lock.json b/package-lock.json index b65ab05f..479690cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "d3-time-format": "3.0.0", "d3-transition": "3.0.1", "dayjs": "1.11.7", + "echarts": "^6.0.0", "express": "^5.2.1", "firebase": "^12.10.0", "froala-editor": "4.0.15", @@ -22971,6 +22972,22 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/ee-first": { "version": "1.1.1", "license": "MIT" @@ -39735,6 +39752,21 @@ "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/package.json b/package.json index 29abe383..6bb01d42 100755 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "d3-time-format": "3.0.0", "d3-transition": "3.0.1", "dayjs": "1.11.7", + "echarts": "^6.0.0", "express": "^5.2.1", "firebase": "^12.10.0", "froala-editor": "4.0.15", diff --git a/tsconfig.base.json b/tsconfig.base.json index 9419cbf4..b5543d95 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -48,6 +48,7 @@ ], "@proxy/services/login": ["libs/services/login/src/index.ts"], "@proxy/services/proxy/auth": ["libs/services/proxy/auth/src/index.ts"], + "@proxy/services/proxy/analytics": ["libs/services/proxy/analytics/src/index.ts"], "@proxy/services/proxy/create-project": ["libs/services/proxy/create-project/src/index.ts"], "@proxy/services/proxy/features": ["libs/services/proxy/features/src/index.ts"], "@proxy/services/proxy/logs": ["libs/services/proxy/logs/src/index.ts"], @@ -66,6 +67,7 @@ "@proxy/ui/search": ["libs/ui/search/src/index.ts"], "@proxy/ui/service-list": ["libs/ui/service-list/src/index.ts"], "@proxy/ui/virtual-scroll": ["libs/ui/virtual-scroll/src/index.ts"], + "@proxy/urls/analytics-urls": ["libs/urls/analytics-urls/src/index.ts"], "@proxy/urls/create-project-urls": ["libs/urls/create-project-urls/src/index.ts"], "@proxy/urls/features-url": ["libs/urls/features-url/src/index.ts"], "@proxy/urls/logs-urls": ["libs/urls/logs-urls/src/index.ts"], From 05618fffff85b54eca395a69621655b4904848f3 Mon Sep 17 00:00:00 2001 From: Saurabh186 Date: Wed, 15 Apr 2026 17:16:27 +0530 Subject: [PATCH 04/13] fix-pr changes --- .../breakdown-chart.component.html | 16 +- .../breakdown-chart.component.ts | 93 ++++++------ .../breakdown-expand-dialog.component.ts | 74 --------- .../panel/dashboard/dashboard.component.html | 16 +- .../panel/dashboard/dashboard.component.ts | 56 +------ .../app/panel/dashboard/dashboard.models.ts | 143 ++++++++++++++++++ .../chart-expand-dialog.component.ts | 88 ----------- .../timeseries-chart.component.html | 37 ++--- .../timeseries-chart.component.ts | 126 ++++++++------- .../analytics/src/lib/analytics.service.ts | 4 - libs/urls/analytics-urls/src/index.ts | 1 - 11 files changed, 310 insertions(+), 344 deletions(-) delete mode 100644 apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-expand-dialog.component.ts create mode 100644 apps/36-blocks/src/app/panel/dashboard/dashboard.models.ts delete mode 100644 apps/36-blocks/src/app/panel/dashboard/timeseries-chart/chart-expand-dialog.component.ts diff --git a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.html b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.html index bd0e5e16..c0d4e59a 100644 --- a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.html +++ b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.html @@ -33,7 +33,7 @@ @if (!isLoading() && !error()) { @if (chartData().length) { -
+
@@ -60,3 +60,17 @@ } }
+ + +
+ + Breakdown โ€” {{ groupByOptions.find(o => o.value === selectedGroupBy())?.label ?? '' }} + + +
+
+
+
+
diff --git a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts index e099aef0..1e562b2f 100644 --- a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts +++ b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts @@ -3,6 +3,7 @@ import { Component, ElementRef, OnDestroy, + TemplateRef, ViewChild, effect, inject, @@ -20,14 +21,12 @@ import { takeUntil } from 'rxjs'; import * as echarts from 'echarts'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { MatDialog } from '@angular/material/dialog'; -import { BreakdownExpandDialogComponent } from './breakdown-expand-dialog.component'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { INFO_TOOLTIPS } from '@proxy/constant'; - -export type BreakdownGroupBy = 'service_id' | 'source' | 'type'; +import { BreakdownGroupBy, IBreakdownGroupByOption, GROUP_BY_OPTIONS } from '../dashboard.models'; @Component({ - selector: 'proxy-breakdown-chart', + selector: 'breakdown-chart', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, @@ -43,12 +42,14 @@ export type BreakdownGroupBy = 'service_id' | 'source' | 'type'; export class BreakdownChartComponent extends BaseComponent implements OnDestroy { readonly infoTooltip = INFO_TOOLTIPS.dashboard.charts.breakdown; @ViewChild('chartEl', { static: false }) chartEl!: ElementRef; + @ViewChild('expandTpl', { static: false }) expandTpl!: TemplateRef; range = input('week'); featureConfigurationId = input(null); private analyticsService = inject(AnalyticsService); private dialog = inject(MatDialog); + private expandDialogRef: MatDialogRef | null = null; private chart: echarts.ECharts | null = null; private themeObserver: MutationObserver | null = null; @@ -56,13 +57,9 @@ export class BreakdownChartComponent extends BaseComponent implements OnDestroy readonly error = signal(null); readonly chartData = signal<{ key: string; count: number }[]>([]); - selectedGroupBy = signal('type'); + selectedGroupBy = signal(BreakdownGroupBy.type); - readonly groupByOptions: { label: string; value: BreakdownGroupBy }[] = [ - { label: 'By Type', value: 'type' }, - // { label: 'By Source', value: 'source' }, - { label: 'By Service', value: 'service_id' }, - ]; + readonly groupByOptions: IBreakdownGroupByOption[] = GROUP_BY_OPTIONS; readonly palette = ['#6366f1', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444']; @@ -93,7 +90,7 @@ export class BreakdownChartComponent extends BaseComponent implements OnDestroy const data = res?.data?.data ?? []; this.chartData.set(data); this.isLoading.set(false); - setTimeout(() => this.renderChart(data), 0); + setTimeout(() => this.renderChart(), 0); }, error: (err: any) => { this.error.set(err?.message ?? 'Failed to load breakdown'); @@ -106,16 +103,20 @@ export class BreakdownChartComponent extends BaseComponent implements OnDestroy this.selectedGroupBy.set(value); } + closeExpand(): void { + this.expandDialogRef?.close(); + } + openExpand(): void { - this.dialog.open(BreakdownExpandDialogComponent, { - data: { - data: this.chartData(), - palette: this.palette, - label: - 'Breakdown โ€” ' + (this.groupByOptions.find((o) => o.value === this.selectedGroupBy())?.label ?? ''), - }, - width: '80vw', - maxWidth: '900px', + const ref = (this.expandDialogRef = this.dialog.open(this.expandTpl, { + panelClass: ['mat-dialog', 'mat-dialog-lg'], + })); + ref.afterOpened().subscribe(() => { + const el = document.querySelector('[data-expand-chart="breakdown"]') as HTMLDivElement; + if (!el) return; + const expandChart = echarts.init(el); + expandChart.setOption(this.getPieOption({ expanded: true })); + ref.afterClosed().subscribe(() => expandChart.dispose()); }); } @@ -126,49 +127,51 @@ export class BreakdownChartComponent extends BaseComponent implements OnDestroy private watchTheme(): void { this.themeObserver?.disconnect(); this.themeObserver = new MutationObserver(() => { - if (this.chartData().length) this.renderChart(this.chartData()); + if (this.chartData().length) this.renderChart(); }); this.themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); } - private renderChart(data: { key: string; count: number }[]): void { - if (!this.chartEl?.nativeElement) return; - if (this.chart) { - this.chart.dispose(); - } - - const borderColor = this.getCssVar('--color-common-border'); - const textColor = this.getCssVar('--color-common-text'); - const bgColor = this.getCssVar('--color-common-bg'); - - this.chart = echarts.init(this.chartEl.nativeElement); - this.chart.setOption({ + private getPieOption(opts: { expanded?: boolean } = {}): any { + const borderColor = this.chartEl?.nativeElement ? this.getCssVar('--color-common-border') : ''; + const textColor = this.chartEl?.nativeElement ? this.getCssVar('--color-common-text') : ''; + const bgColor = this.chartEl?.nativeElement ? this.getCssVar('--color-common-bg') : ''; + return { backgroundColor: 'transparent', tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)', - backgroundColor: bgColor, - borderColor: borderColor, - textStyle: { color: textColor }, + ...(bgColor ? { backgroundColor: bgColor, borderColor, textStyle: { color: textColor } } : {}), + }, + legend: { + bottom: 0, + left: 'center', + textStyle: { fontSize: opts.expanded ? 12 : 11, color: textColor || undefined }, }, - legend: { bottom: 0, left: 'center', textStyle: { fontSize: 11, color: textColor } }, series: [ { type: 'pie', - radius: ['40%', '70%'], + radius: opts.expanded ? ['38%', '65%'] : ['40%', '70%'], center: ['50%', '45%'], - data: data.map((d, i) => ({ + data: this.chartData().map((d, i) => ({ name: d.key, value: d.count, itemStyle: { color: this.palette[i % this.palette.length] }, })), - label: { show: false }, - emphasis: { - itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.2)' }, - }, + label: opts.expanded ? { show: true, formatter: '{b}\n{d}%', fontSize: 12 } : { show: false }, + emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.2)' } }, }, ], - }); + }; + } + + private renderChart(): void { + if (!this.chartEl?.nativeElement) return; + if (this.chart) { + this.chart.dispose(); + } + this.chart = echarts.init(this.chartEl.nativeElement); + this.chart.setOption(this.getPieOption()); } override ngOnDestroy(): void { diff --git a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-expand-dialog.component.ts b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-expand-dialog.component.ts deleted file mode 100644 index f87f75ae..00000000 --- a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-expand-dialog.component.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Component, ElementRef, Inject, OnDestroy, ViewChild, AfterViewInit } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import * as echarts from 'echarts'; - -export interface BreakdownDialogData { - data: { key: string; count: number }[]; - palette: string[]; - label: string; -} - -@Component({ - selector: 'proxy-breakdown-expand-dialog', - standalone: true, - imports: [MatDialogModule, MatButtonModule, MatIconModule], - template: ` -
- {{ data.label }} - -
- -
-
- `, -}) -export class BreakdownExpandDialogComponent implements AfterViewInit, OnDestroy { - @ViewChild('chartEl', { static: false }) chartEl!: ElementRef; - private chart: echarts.ECharts | null = null; - - constructor( - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: BreakdownDialogData - ) {} - - ngAfterViewInit(): void { - setTimeout(() => this.renderChart(), 0); - } - - private renderChart(): void { - if (!this.chartEl?.nativeElement) return; - this.chart = echarts.init(this.chartEl.nativeElement); - this.chart.setOption({ - tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, - legend: { bottom: 0, left: 'center', textStyle: { fontSize: 12 } }, - series: [ - { - type: 'pie', - radius: ['38%', '65%'], - center: ['50%', '45%'], - data: this.data.data.map((d, i) => ({ - name: d.key, - value: d.count, - itemStyle: { color: this.data.palette[i % this.data.palette.length] }, - })), - label: { show: true, formatter: '{b}\n{d}%', fontSize: 12 }, - emphasis: { - itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.2)' }, - }, - }, - ], - }); - } - - close(): void { - this.dialogRef.close(); - } - - ngOnDestroy(): void { - this.chart?.dispose(); - } -} diff --git a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html index 8f4b40a5..40abeaae 100644 --- a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html +++ b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html @@ -45,8 +45,10 @@

Analytics Overview

@for (card of overviewCards; track card.key) {
- - {{ card.icon }} + + {{ card.icon }} {{ card.label }} Analytics Overview

- - + +
diff --git a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts index c80f809d..18a6f062 100644 --- a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts +++ b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, OnInit, computed, effect, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { DateRange, OVERVIEW_CARDS, RANGE_OPTIONS } from './dashboard.models'; import { RouterModule } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; @@ -13,11 +14,8 @@ import { FeaturesService } from '@proxy/services/proxy/features'; import { IFeature } from '@proxy/models/features-model'; import { TimeseriesChartComponent } from './timeseries-chart/timeseries-chart.component'; import { BreakdownChartComponent } from './breakdown-chart/breakdown-chart.component'; -import { INFO_TOOLTIPS } from '@proxy/constant'; import { takeUntil } from 'rxjs'; -export type DateRange = 'day' | 'week' | 'month'; // dashboard - @Component({ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'proxy-dashboard', @@ -34,7 +32,6 @@ export type DateRange = 'day' | 'week' | 'month'; // dashboard BreakdownChartComponent, ], templateUrl: './dashboard.component.html', - styleUrl: './dashboard.component.scss', }) export class DashboardComponent extends BaseComponent implements OnInit { private analyticsService = inject(AnalyticsService); @@ -43,7 +40,7 @@ export class DashboardComponent extends BaseComponent implements OnInit { readonly features = signal([]); readonly isLoadingFeatures = signal(false); - readonly range = signal('week'); + readonly range = signal(DateRange.week); readonly featureConfigurationId = signal(''); readonly overviewData = signal(null); @@ -57,58 +54,13 @@ export class DashboardComponent extends BaseComponent implements OnInit { return base; }); - readonly overviewCards = [ - { - key: 'signups', - valueKey: 'signups.total', - label: 'Signups', - icon: 'person_add', - bgClass: 'bg-indigo-100 dark:bg-indigo-900/40', - iconClass: 'text-indigo-500', - sub: 'in selected period', - infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.signups, - }, - { - key: 'logins', - valueKey: 'logins.total', - label: 'Logins', - icon: 'login', - bgClass: 'bg-violet-100 dark:bg-violet-900/40', - iconClass: 'text-violet-500', - sub: 'in selected period', - infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.logins, - }, - { - key: 'active_users', - valueKey: 'active_users.unique', - label: 'Active Users', - icon: 'group', - bgClass: 'bg-cyan-100 dark:bg-cyan-900/40', - iconClass: 'text-cyan-500', - sub: 'unique in period', - infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.active_users, - }, - { - key: 'users', - valueKey: 'users.client_total', - label: 'Total Users', - icon: 'people', - bgClass: 'bg-emerald-100 dark:bg-emerald-900/40', - iconClass: 'text-emerald-500', - sub: 'registered total', - infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.users, - }, - ]; + readonly overviewCards = OVERVIEW_CARDS; getCardValue(data: any, path: string): number { return path.split('.').reduce((acc, k) => acc?.[k], data) ?? 0; } - readonly rangeOptions: { label: string; value: DateRange }[] = [ - { label: 'Today', value: 'day' }, - { label: 'This Week', value: 'week' }, - { label: 'This Month', value: 'month' }, - ]; + readonly rangeOptions = RANGE_OPTIONS; constructor() { super(); diff --git a/apps/36-blocks/src/app/panel/dashboard/dashboard.models.ts b/apps/36-blocks/src/app/panel/dashboard/dashboard.models.ts new file mode 100644 index 00000000..1aa5826f --- /dev/null +++ b/apps/36-blocks/src/app/panel/dashboard/dashboard.models.ts @@ -0,0 +1,143 @@ +import { INFO_TOOLTIPS } from '@proxy/constant'; + +export enum DateRange { + day = 'day', + week = 'week', + month = 'month', +} + +export interface IRangeOption { + label: string; + value: DateRange; +} + +export interface IOverviewCard { + key: string; + valueKey: string; + label: string; + icon: string; + sub: string; + infoTooltip: string; +} + +export enum TimeseriesMetric { + logins = 'logins', + signups = 'signups', + active_users = 'active_users', +} + +export enum TimeseriesInterval { + hour = 'hour', + day = 'day', + week = 'week', +} + +export interface IIntervalOption { + label: string; + value: TimeseriesInterval; +} + +export interface ITimeseriesMetricConfig { + label: string; + value: TimeseriesMetric; + color: string; + infoTooltip: string; +} + +export interface ITimeseriesSeries { + label: string; + color: string; + infoTooltip: string; + data: { period: string; value: number }[]; +} + +export enum BreakdownGroupBy { + service_id = 'service_id', + source = 'source', + type = 'type', +} + +export interface IBreakdownGroupByOption { + label: string; + value: BreakdownGroupBy; +} + +export const OVERVIEW_CARDS: IOverviewCard[] = [ + { + key: 'users', + valueKey: 'users.client_total', + label: 'Total Users', + icon: 'people', + sub: 'registered total', + infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.users, + }, + { + key: 'signups', + valueKey: 'signups.total', + label: 'Signups', + icon: 'person_add', + sub: 'in selected period', + infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.signups, + }, + { + key: 'logins', + valueKey: 'logins.total', + label: 'Logins', + icon: 'login', + sub: 'in selected period', + infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.logins, + }, + { + key: 'active_users', + valueKey: 'active_users.unique', + label: 'Active Users', + icon: 'group', + sub: 'unique in period', + infoTooltip: INFO_TOOLTIPS.dashboard.overviewCards.active_users, + }, +]; + +export const RANGE_OPTIONS: IRangeOption[] = [ + { label: 'Today', value: DateRange.day }, + { label: 'This Week', value: DateRange.week }, + { label: 'This Month', value: DateRange.month }, +]; + +export const TIMESERIES_METRICS: Record = { + [TimeseriesMetric.signups]: { + label: 'Signups', + value: TimeseriesMetric.signups, + color: '#10b981', + infoTooltip: INFO_TOOLTIPS.dashboard.charts.signups, + }, + [TimeseriesMetric.logins]: { + label: 'Logins', + value: TimeseriesMetric.logins, + color: '#6366f1', + infoTooltip: INFO_TOOLTIPS.dashboard.charts.logins, + }, + [TimeseriesMetric.active_users]: { + label: 'Active Users', + value: TimeseriesMetric.active_users, + color: '#f59e0b', + infoTooltip: INFO_TOOLTIPS.dashboard.charts.active_users, + }, +}; + +export const INTERVAL_OPTIONS: Record = { + [TimeseriesInterval.hour]: { label: 'Hourly', value: TimeseriesInterval.hour }, + [TimeseriesInterval.day]: { label: 'Daily', value: TimeseriesInterval.day }, + [TimeseriesInterval.week]: { label: 'Weekly', value: TimeseriesInterval.week }, +}; + +export const GROUP_BY_OPTIONS: IBreakdownGroupByOption[] = [ + { label: 'By Type', value: BreakdownGroupBy.type }, + // { label: 'By Source', value: BreakdownGroupBy.source }, + { label: 'By Service', value: BreakdownGroupBy.service_id }, +]; + +export function intervalForRange(range: string): TimeseriesInterval { + if (range === DateRange.day) return TimeseriesInterval.hour; + if (range === DateRange.month) return TimeseriesInterval.week; + return TimeseriesInterval.day; +} diff --git a/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/chart-expand-dialog.component.ts b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/chart-expand-dialog.component.ts deleted file mode 100644 index b2c124d7..00000000 --- a/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/chart-expand-dialog.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Component, ElementRef, Inject, OnDestroy, ViewChild, AfterViewInit } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import * as echarts from 'echarts'; - -export interface ChartDialogData { - label: string; - color: string; - data: { period: string; value: number }[]; -} - -@Component({ - selector: 'proxy-chart-expand-dialog', - standalone: true, - imports: [MatDialogModule, MatButtonModule, MatIconModule], - template: ` -
- {{ data.label }} - -
- -
-
- `, -}) -export class ChartExpandDialogComponent implements AfterViewInit, OnDestroy { - @ViewChild('chartEl', { static: false }) chartEl!: ElementRef; - private chart: echarts.ECharts | null = null; - - constructor( - public dialogRef: MatDialogRef, - @Inject(MAT_DIALOG_DATA) public data: ChartDialogData - ) {} - - ngAfterViewInit(): void { - setTimeout(() => this.renderChart(), 0); - } - - private renderChart(): void { - if (!this.chartEl?.nativeElement) return; - this.chart = echarts.init(this.chartEl.nativeElement); - this.chart.setOption({ - tooltip: { trigger: 'axis' }, - grid: { top: 16, right: 24, bottom: 40, left: 56 }, - xAxis: { - type: 'category', - data: this.data.data.map((d) => d.period), - axisLabel: { fontSize: 11 }, - axisLine: { lineStyle: { color: '#e5e7eb' } }, - splitLine: { show: false }, - }, - yAxis: { - type: 'value', - axisLabel: { fontSize: 11 }, - splitLine: { lineStyle: { color: '#e5e7eb' } }, - }, - series: [ - { - name: this.data.label, - type: 'line', - data: this.data.data.map((d) => d.value), - smooth: true, - symbol: 'circle', - symbolSize: 6, - lineStyle: { width: 2, color: this.data.color }, - itemStyle: { color: this.data.color }, - areaStyle: { - color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: this.data.color + '33' }, - { offset: 1, color: this.data.color + '05' }, - ]), - }, - }, - ], - }); - } - - close(): void { - this.dialogRef.close(); - } - - ngOnDestroy(): void { - this.chart?.dispose(); - } -} diff --git a/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.html b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.html index 59feb416..0acb9c20 100644 --- a/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.html +++ b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.html @@ -1,14 +1,3 @@ - - @if (isLoading()) {
@@ -18,12 +7,12 @@ } - +@if (error()) { +
+ error_outline + {{ error() }} +
+} @if (isLoading()) { @@ -53,7 +42,7 @@ open_in_full
-
+
} } @@ -65,3 +54,15 @@ No data for this period } + + +
+ {{ expandSeries()?.label }} + +
+
+
+
+
diff --git a/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.ts b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.ts index 4775ac13..9d3865df 100644 --- a/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.ts +++ b/apps/36-blocks/src/app/panel/dashboard/timeseries-chart/timeseries-chart.component.ts @@ -4,6 +4,8 @@ import { ElementRef, OnDestroy, QueryList, + TemplateRef, + ViewChild, ViewChildren, effect, inject, @@ -22,16 +24,17 @@ import * as echarts from 'echarts'; import { MatCardModule } from '@angular/material/card'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { MatDialog } from '@angular/material/dialog'; -import { ChartExpandDialogComponent } from './chart-expand-dialog.component'; -import { INFO_TOOLTIPS } from '@proxy/constant'; - -// chart component -export type TimeseriesMetric = 'logins' | 'signups' | 'active_users'; -export type TimeseriesInterval = 'hour' | 'day' | 'week'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { + TimeseriesInterval, + ITimeseriesSeries, + INTERVAL_OPTIONS, + TIMESERIES_METRICS, + intervalForRange, +} from '../dashboard.models'; @Component({ - selector: 'proxy-timeseries-chart', + selector: 'timeseries-chart', changeDetection: ChangeDetectionStrategy.OnPush, host: { style: 'display: contents' }, imports: [ @@ -48,49 +51,31 @@ export type TimeseriesInterval = 'hour' | 'day' | 'week'; }) export class TimeseriesChartComponent extends BaseComponent implements OnDestroy { @ViewChildren('chartEl') chartEls!: QueryList>; + @ViewChild('expandTpl', { static: false }) expandTpl!: TemplateRef; range = input('week'); featureConfigurationId = input(null); private analyticsService = inject(AnalyticsService); private dialog = inject(MatDialog); + private expandDialogRef: MatDialogRef | null = null; readonly isLoading = signal(false); readonly error = signal(null); - readonly seriesList = signal< - { label: string; color: string; infoTooltip: string; data: { period: string; value: number }[] }[] - >([]); - - selectedInterval = signal('day'); - - readonly metrics: { label: string; value: TimeseriesMetric; color: string; infoTooltip: string }[] = [ - { label: 'Logins', value: 'logins', color: '#6366f1', infoTooltip: INFO_TOOLTIPS.dashboard.charts.logins }, - { label: 'Signups', value: 'signups', color: '#10b981', infoTooltip: INFO_TOOLTIPS.dashboard.charts.signups }, - { - label: 'Active Users', - value: 'active_users', - color: '#f59e0b', - infoTooltip: INFO_TOOLTIPS.dashboard.charts.active_users, - }, - ]; - - readonly intervalOptions: { label: string; value: TimeseriesInterval }[] = [ - { label: 'Hourly', value: 'hour' }, - { label: 'Daily', value: 'day' }, - { label: 'Weekly', value: 'week' }, - ]; - - private intervalForRange(range: string): TimeseriesInterval { - if (range === 'day') return 'hour'; - if (range === 'month') return 'week'; - return 'day'; - } + readonly seriesList = signal([]); + readonly expandSeries = signal(null); + + selectedInterval = signal(TimeseriesInterval.day); + + readonly metrics = TIMESERIES_METRICS; + + readonly intervalOptions = INTERVAL_OPTIONS; constructor() { super(); effect(() => { const range = this.range(); - this.selectedInterval.set(this.intervalForRange(range)); + this.selectedInterval.set(intervalForRange(range)); const fcId = this.featureConfigurationId(); this.fetchAllMetrics(fcId); }); @@ -102,7 +87,7 @@ export class TimeseriesChartComponent extends BaseComponent implements OnDestroy this.error.set(null); this.seriesList.set([]); - const calls = this.metrics.map((m) => { + const calls = Object.values(this.metrics).map((m) => { const params: ITimeseriesParams = { range: this.range() as any, metric: m.value, @@ -116,10 +101,11 @@ export class TimeseriesChartComponent extends BaseComponent implements OnDestroy .pipe(takeUntil(this.destroy$)) .subscribe({ next: (results: any[]) => { + const metricValues = Object.values(this.metrics); const list = results.map((res, i) => ({ - label: this.metrics[i].label, - color: this.metrics[i].color, - infoTooltip: this.metrics[i].infoTooltip, + label: metricValues[i]!.label, + color: metricValues[i]!.color, + infoTooltip: metricValues[i]!.infoTooltip, data: (res?.data?.data ?? []) as { period: string; value: number }[], })); this.seriesList.set(list); @@ -207,16 +193,54 @@ export class TimeseriesChartComponent extends BaseComponent implements OnDestroy }); } - openExpand(s: { - label: string; - color: string; - infoTooltip: string; - data: { period: string; value: number }[]; - }): void { - this.dialog.open(ChartExpandDialogComponent, { - data: s, - width: '80vw', - maxWidth: '1000px', + closeExpand(): void { + this.expandDialogRef?.close(); + } + + openExpand(s: ITimeseriesSeries): void { + this.expandSeries.set(s); + const ref = (this.expandDialogRef = this.dialog.open(this.expandTpl, { + panelClass: ['mat-dialog', 'mat-dialog-lg'], + })); + ref.afterOpened().subscribe(() => { + const el = document.querySelector('[data-expand-chart="timeseries"]') as HTMLDivElement; + if (!el) return; + const expandChart = echarts.init(el); + expandChart.setOption({ + tooltip: { trigger: 'axis' }, + grid: { top: 16, right: 24, bottom: 40, left: 56 }, + xAxis: { + type: 'category', + data: s.data.map((d) => d.period), + axisLabel: { fontSize: 11 }, + axisLine: { lineStyle: { color: '#e5e7eb' } }, + splitLine: { show: false }, + }, + yAxis: { + type: 'value', + axisLabel: { fontSize: 11 }, + splitLine: { lineStyle: { color: '#e5e7eb' } }, + }, + series: [ + { + name: s.label, + type: 'line', + data: s.data.map((d) => d.value), + smooth: true, + symbol: 'circle', + symbolSize: 6, + lineStyle: { width: 2, color: s.color }, + itemStyle: { color: s.color }, + areaStyle: { + color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ + { offset: 0, color: s.color + '33' }, + { offset: 1, color: s.color + '05' }, + ]), + }, + }, + ], + }); + ref.afterClosed().subscribe(() => expandChart.dispose()); }); } diff --git a/libs/services/proxy/analytics/src/lib/analytics.service.ts b/libs/services/proxy/analytics/src/lib/analytics.service.ts index df69f7f4..f4da64ff 100644 --- a/libs/services/proxy/analytics/src/lib/analytics.service.ts +++ b/libs/services/proxy/analytics/src/lib/analytics.service.ts @@ -37,10 +37,6 @@ export class AnalyticsService { return this.http.get(AnalyticsUrls.timeseries(this.baseURL), params); } - public getActiveUsers(params: IAnalyticsParams): Observable { - return this.http.get(AnalyticsUrls.activeUsers(this.baseURL), params); - } - public getBreakdown(params: IBreakdownParams): Observable { return this.http.get(AnalyticsUrls.breakdown(this.baseURL), params); } diff --git a/libs/urls/analytics-urls/src/index.ts b/libs/urls/analytics-urls/src/index.ts index 89b9ae74..b6c3f0bb 100644 --- a/libs/urls/analytics-urls/src/index.ts +++ b/libs/urls/analytics-urls/src/index.ts @@ -3,6 +3,5 @@ import { createUrl } from '@proxy/service'; export const AnalyticsUrls = { overview: (baseUrl: string) => createUrl(baseUrl, 'analytics/overview'), timeseries: (baseUrl: string) => createUrl(baseUrl, 'analytics/timeseries'), - activeUsers: (baseUrl: string) => createUrl(baseUrl, 'analytics/active-users'), breakdown: (baseUrl: string) => createUrl(baseUrl, 'analytics/breakdown'), }; From 54086ee35f7ae792ac1c5128687c856a57cf43f1 Mon Sep 17 00:00:00 2001 From: Giddh's Black Tiger Date: Fri, 17 Apr 2026 13:03:34 +0530 Subject: [PATCH 05/13] refactor: enhance dashboard with feature search and add name validation to user management - Add autocomplete search functionality for feature/block filtering in dashboard - Replace dropdown with searchable input field with clear button - Add filteredFeatures computed signal for real-time search filtering - Add pattern validation for user name field in user management form - Display "Invalid name format" error message when name pattern validation fails - Add proxy-auth.js asset configuration to --- .../user-management.component.html | 6 ++++ .../user-management.component.ts | 3 +- apps/36-blocks/project.json | 5 +++ .../breakdown-chart.component.ts | 15 +++++++-- .../panel/dashboard/dashboard.component.html | 32 +++++++++++++------ .../panel/dashboard/dashboard.component.ts | 28 ++++++++++++++-- .../analytics/src/lib/analytics.service.ts | 5 ++- 7 files changed, 78 insertions(+), 16 deletions(-) diff --git a/apps/36-blocks-widget/src/app/otp/user-management/user-management.component.html b/apps/36-blocks-widget/src/app/otp/user-management/user-management.component.html index 6aeed9cd..f8752656 100644 --- a/apps/36-blocks-widget/src/app/otp/user-management/user-management.component.html +++ b/apps/36-blocks-widget/src/app/otp/user-management/user-management.component.html @@ -836,6 +836,12 @@

addUserForm.get('name')?.hasError('required') ) { + } @else if ( + addUserForm.get('name')?.touched && addUserForm.get('name')?.hasError('pattern') + ) { + }
diff --git a/apps/36-blocks-widget/src/app/otp/user-management/user-management.component.ts b/apps/36-blocks-widget/src/app/otp/user-management/user-management.component.ts index e2c4985b..f0e3586e 100644 --- a/apps/36-blocks-widget/src/app/otp/user-management/user-management.component.ts +++ b/apps/36-blocks-widget/src/app/otp/user-management/user-management.component.ts @@ -21,6 +21,7 @@ import { CommonModule } from '@angular/common'; import { ToastService } from '../service/toast.service'; import { ToastComponent } from '../service/toast.component'; import { WidgetTheme } from '@proxy/constant'; +import { UPDATE_REGEX } from '@proxy/regex'; import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Actions, ofType } from '@ngrx/effects'; @@ -309,7 +310,7 @@ export class UserManagementComponent implements OnInit, AfterViewInit, OnDestroy }); this.addUserForm = this.fb.group({ - name: ['', Validators.required], + name: ['', [Validators.required, Validators.pattern(UPDATE_REGEX)]], email: ['', [Validators.required, Validators.email]], mobileNumber: ['', [Validators.pattern(/^(\+?[1-9]\d{1,14}|[0-9]{10})$/)]], role: [''], diff --git a/apps/36-blocks/project.json b/apps/36-blocks/project.json index ef93ea4c..9020115f 100644 --- a/apps/36-blocks/project.json +++ b/apps/36-blocks/project.json @@ -42,6 +42,11 @@ "glob": "intl-tel-input-custom.css", "input": "apps/shared/assets/utils", "output": "assets/utils" + }, + { + "glob": "proxy-auth.js", + "input": "dist/apps/36-blocks/browser/assets/proxy-auth", + "output": "assets/proxy-auth" } ], "styles": [ diff --git a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts index 1e562b2f..37701795 100644 --- a/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts +++ b/apps/36-blocks/src/app/panel/dashboard/breakdown-chart/breakdown-chart.component.ts @@ -23,7 +23,13 @@ import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { INFO_TOOLTIPS } from '@proxy/constant'; -import { BreakdownGroupBy, IBreakdownGroupByOption, GROUP_BY_OPTIONS } from '../dashboard.models'; +import { + BreakdownGroupBy, + IBreakdownGroupByOption, + GROUP_BY_OPTIONS, + TimeseriesInterval, + intervalForRange, +} from '../dashboard.models'; @Component({ selector: 'breakdown-chart', @@ -58,6 +64,7 @@ export class BreakdownChartComponent extends BaseComponent implements OnDestroy readonly chartData = signal<{ key: string; count: number }[]>([]); selectedGroupBy = signal(BreakdownGroupBy.type); + selectedInterval = signal(TimeseriesInterval.day); readonly groupByOptions: IBreakdownGroupByOption[] = GROUP_BY_OPTIONS; @@ -67,9 +74,13 @@ export class BreakdownChartComponent extends BaseComponent implements OnDestroy super(); this.watchTheme(); effect(() => { + const range = this.range(); + const interval = intervalForRange(range); + this.selectedInterval.set(interval); const params: IBreakdownParams = { - range: this.range() as any, + range: range as any, group_by: this.selectedGroupBy(), + interval, }; const fcId = this.featureConfigurationId(); if (fcId) params.feature_configuration_id = fcId; diff --git a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html index 40abeaae..f246a3d1 100644 --- a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html +++ b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html @@ -3,15 +3,29 @@

Analytics Overview

- - - Client - - @for (f of features(); track f.id) { - {{ f.name }} - } - - + + search + + @if (featureConfigurationId()) { + + } + + All Blocks + @for (feature of filteredFeatures(); track feature.id) { + {{ feature.name }} + } + @if (filteredFeatures().length === 0 && featureSearchControl.value) { + No blocks found + } + diff --git a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts index 18a6f062..d6547823 100644 --- a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts +++ b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component, OnInit, computed, effect, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { DateRange, OVERVIEW_CARDS, RANGE_OPTIONS } from './dashboard.models'; import { RouterModule } from '@angular/router'; @@ -7,7 +8,9 @@ import { MatSelectModule } from '@angular/material/select'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatButtonModule } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule, FormControl } from '@angular/forms'; +import { MatAutocompleteModule, MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { MatInputModule } from '@angular/material/input'; import { BaseComponent } from '@proxy/ui/base-component'; import { AnalyticsService, IAnalyticsParams } from '@proxy/services/proxy/analytics'; import { FeaturesService } from '@proxy/services/proxy/features'; @@ -28,6 +31,9 @@ import { takeUntil } from 'rxjs'; MatButtonModule, MatTooltipModule, FormsModule, + ReactiveFormsModule, + MatAutocompleteModule, + MatInputModule, TimeseriesChartComponent, BreakdownChartComponent, ], @@ -40,6 +46,15 @@ export class DashboardComponent extends BaseComponent implements OnInit { readonly features = signal([]); readonly isLoadingFeatures = signal(false); + readonly featureSearchControl = new FormControl(''); + private readonly featureSearchValue = toSignal(this.featureSearchControl.valueChanges, { initialValue: '' }); + + readonly filteredFeatures = computed(() => { + const query = (this.featureSearchValue() ?? '').toLowerCase().trim(); + if (!query) return this.features(); + return this.features().filter((feature) => feature.name?.toLowerCase().includes(query)); + }); + readonly range = signal(DateRange.week); readonly featureConfigurationId = signal(''); @@ -88,8 +103,15 @@ export class DashboardComponent extends BaseComponent implements OnInit { }); } - onFeatureChange(id: number | null): void { - this.featureConfigurationId.set(id); + onFeatureSelected(event: MatAutocompleteSelectedEvent): void { + const selectedFeature = this.features().find((feature) => feature.id === event.option.value); + this.featureConfigurationId.set(event.option.value ?? ''); + this.featureSearchControl.setValue(selectedFeature?.name ?? '', { emitEvent: false }); + } + + onFeatureInputClear(): void { + this.featureSearchControl.setValue(''); + this.featureConfigurationId.set(''); } fetchOverview(params: IAnalyticsParams): void { diff --git a/libs/services/proxy/analytics/src/lib/analytics.service.ts b/libs/services/proxy/analytics/src/lib/analytics.service.ts index f4da64ff..8dea7120 100644 --- a/libs/services/proxy/analytics/src/lib/analytics.service.ts +++ b/libs/services/proxy/analytics/src/lib/analytics.service.ts @@ -4,6 +4,8 @@ import { ProxyBaseUrls } from '@proxy/models/root-models'; import { Observable } from 'rxjs'; import { AnalyticsUrls } from '@proxy/urls/analytics-urls'; +type Interval = 'hour' | 'day' | 'week'; + export interface IAnalyticsParams { feature_configuration_id?: number; range?: 'day' | 'week' | 'month'; @@ -13,11 +15,12 @@ export interface IAnalyticsParams { export interface ITimeseriesParams extends IAnalyticsParams { metric: 'signups' | 'logins' | 'active_users'; - interval: 'hour' | 'day' | 'week'; + interval: Interval; } export interface IBreakdownParams extends IAnalyticsParams { group_by: 'service_id' | 'source' | 'type'; + interval?: Interval; } @Injectable({ From e5d72f5299fc2454719415f1cf3b8da7d4f6f461 Mon Sep 17 00:00:00 2001 From: Giddh's Black Tiger Date: Fri, 17 Apr 2026 19:34:59 +0530 Subject: [PATCH 06/13] feat(dashboard): add autocomplete block filter with interval param in breakdown --- .../panel/dashboard/dashboard.component.html | 9 +++++++-- .../app/panel/dashboard/dashboard.component.ts | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html index f246a3d1..e17631c1 100644 --- a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html +++ b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.html @@ -11,18 +11,23 @@

Analytics Overview

placeholder="All Blocks" [formControl]="featureSearchControl" [matAutocomplete]="featureAutocomplete" + (input)="onFeatureInputChange($any($event.target).value)" /> @if (featureConfigurationId()) { } - + All Blocks @for (feature of filteredFeatures(); track feature.id) { {{ feature.name }} } - @if (filteredFeatures().length === 0 && featureSearchControl.value) { + @if (filteredFeatures().length === 0 && featureSearchQuery()) { No blocks found } diff --git a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts index d6547823..22270074 100644 --- a/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts +++ b/apps/36-blocks/src/app/panel/dashboard/dashboard.component.ts @@ -1,5 +1,4 @@ import { ChangeDetectionStrategy, Component, OnInit, computed, effect, inject, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import { DateRange, OVERVIEW_CARDS, RANGE_OPTIONS } from './dashboard.models'; import { RouterModule } from '@angular/router'; @@ -47,10 +46,10 @@ export class DashboardComponent extends BaseComponent implements OnInit { readonly isLoadingFeatures = signal(false); readonly featureSearchControl = new FormControl(''); - private readonly featureSearchValue = toSignal(this.featureSearchControl.valueChanges, { initialValue: '' }); + readonly featureSearchQuery = signal(''); readonly filteredFeatures = computed(() => { - const query = (this.featureSearchValue() ?? '').toLowerCase().trim(); + const query = this.featureSearchQuery().toLowerCase().trim(); if (!query) return this.features(); return this.features().filter((feature) => feature.name?.toLowerCase().includes(query)); }); @@ -103,14 +102,23 @@ export class DashboardComponent extends BaseComponent implements OnInit { }); } + displayFeatureName = (value: number | string | null): string => { + if (!value) return ''; + return this.features().find((feature) => feature.id === value)?.name ?? ''; + }; + + onFeatureInputChange(value: string): void { + this.featureSearchQuery.set(value); + } + onFeatureSelected(event: MatAutocompleteSelectedEvent): void { - const selectedFeature = this.features().find((feature) => feature.id === event.option.value); this.featureConfigurationId.set(event.option.value ?? ''); - this.featureSearchControl.setValue(selectedFeature?.name ?? '', { emitEvent: false }); + this.featureSearchQuery.set(''); } onFeatureInputClear(): void { this.featureSearchControl.setValue(''); + this.featureSearchQuery.set(''); this.featureConfigurationId.set(''); } From 6590bf812563068e0190eadc65841894c9db8315 Mon Sep 17 00:00:00 2001 From: Giddh's Black Tiger Date: Mon, 20 Apr 2026 15:04:12 +0530 Subject: [PATCH 07/13] refactor: add comprehensive documentation and singleton guard to widget build script - Add detailed JSDoc header to build-elements.js explaining script purpose, output location, and usage - Add copyright banner with build timestamp and complete usage examples for all widget types (authorization, user-profile, user-management, organization-details) - Wrap concatenated bundle in singleton guard using window.__proxyAuthLoaded flag to prevent duplicate execution - Import shadow-reset.scss in user-profile, --- apps/36-blocks-widget/build-elements.js | 83 ++++++++++++++++++- .../36-blocks-widget/src/app/app.component.ts | 4 +- .../register/register.component.scss | 34 -------- .../organization-details.component.scss | 1 + .../user-management.component.scss | 1 + .../user-profile/user-profile.component.html | 2 +- .../user-profile/user-profile.component.scss | 1 + .../src/app/otp/widget/widget.component.scss | 1 + .../src/app/otp/widget/widget.component.ts | 7 +- .../src/assets/scss/_shadow-reset.scss | 12 +++ apps/36-blocks-widget/src/styles.scss | 7 -- .../assets/utils/intl-tel-input-custom.scss | 49 +++++------ 12 files changed, 132 insertions(+), 70 deletions(-) create mode 100644 apps/36-blocks-widget/src/assets/scss/_shadow-reset.scss diff --git a/apps/36-blocks-widget/build-elements.js b/apps/36-blocks-widget/build-elements.js index 3e7783bb..c50d0c79 100755 --- a/apps/36-blocks-widget/build-elements.js +++ b/apps/36-blocks-widget/build-elements.js @@ -1,3 +1,17 @@ +/** + * build-elements.js + * + * Post-build script for the 36Blocks widget. + * Concatenates all Angular Element output chunks (polyfills โ†’ vendor โ†’ main), + * inlines the compiled CSS, wraps everything in a singleton guard to prevent + * duplicate execution when the script is loaded more than once, and writes + * the final self-contained bundle to the host app's public assets directory. + * + * Output: dist/apps/36-blocks/browser/assets/proxy-auth/proxy-auth.js + * + * Usage (package.json / nx.json): + * node apps/36-blocks-widget/build-elements.js + */ const fs = require('fs-extra'); const path = require('path'); @@ -69,9 +83,76 @@ const path = require('path'); await fs.ensureDir(distOutDir); + // Wrap the entire bundle in a singleton guard so loading the script a second + // time (e.g. duplicate