Skip to content

Commit fb79172

Browse files
authored
Merge pull request #17 from baasith6/azure-mvp-deploy
ui poliahed
2 parents 611357b + dc1abd4 commit fb79172

55 files changed

Lines changed: 3882 additions & 1579 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/Contracts/Dtos.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ public record AnalyticsSummaryResponse(
131131
int AnalyzedClips,
132132
Dictionary<string, int> AlertsByType);
133133

134+
public record AnalyticsTrendPoint(string Date, int Count);
135+
136+
public record AnalyticsTrendsResponse(int Days, List<AnalyticsTrendPoint> Points);
137+
134138
public record ConnectorLogEntry(
135139
Guid Id,
136140
Guid StoreId,

backend/Controllers/AnalyticsController.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,35 @@ join cam in _db.Cameras on c.CameraId equals cam.Id
5555
.ToDictionary(g => g.Key, g => g.Count())
5656
));
5757
}
58+
59+
[HttpGet("trends")]
60+
public async Task<ActionResult<AnalyticsTrendsResponse>> Trends([FromQuery] Guid? storeId, [FromQuery] int days = 7)
61+
{
62+
if (storeId is not null && !TenantAccess.CanAccessStore(User, storeId.Value))
63+
return Forbid();
64+
65+
days = Math.Clamp(days, 1, 90);
66+
var since = DateTimeOffset.UtcNow.Date.AddDays(1 - days);
67+
68+
var alerts = TenantAccess.ScopeAlerts(_db.Alerts, User).AsNoTracking();
69+
if (storeId is not null)
70+
alerts = alerts.Where(a => a.StoreId == storeId);
71+
72+
var rows = await alerts
73+
.Where(a => a.CreatedAt >= since)
74+
.GroupBy(a => a.CreatedAt.Date)
75+
.Select(g => new { Date = g.Key, Count = g.Count() })
76+
.ToListAsync();
77+
78+
var map = rows.ToDictionary(r => DateOnly.FromDateTime(r.Date), r => r.Count);
79+
var points = new List<AnalyticsTrendPoint>();
80+
for (var i = 0; i < days; i++)
81+
{
82+
var d = DateOnly.FromDateTime(since.AddDays(i));
83+
map.TryGetValue(d, out var count);
84+
points.Add(new AnalyticsTrendPoint(d.ToString("MM-dd"), count));
85+
}
86+
87+
return Ok(new AnalyticsTrendsResponse(days, points));
88+
}
5889
}

dashboard/.vscode/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"typescript.tsdk": "node_modules/typescript/lib",
3+
"typescript.preferences.includePackageJsonAutoImports": "auto"
4+
}

dashboard/angular.json

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@
5252
}
5353
],
5454
"styles": [
55-
"src/styles.css"
55+
"src/app/shared/styles/_tokens.css",
56+
"src/styles.css",
57+
"src/app/shared/styles/_utilities.css",
58+
"src/app/shared/styles/_components.css"
5659
],
5760
"scripts": []
5861
},
@@ -82,6 +85,9 @@
8285
},
8386
"serve": {
8487
"builder": "@angular-devkit/build-angular:dev-server",
88+
"options": {
89+
"proxyConfig": "proxy.conf.json"
90+
},
8591
"configurations": {
8692
"production": {
8793
"buildTarget": "dashboard:build:production"
@@ -110,7 +116,10 @@
110116
}
111117
],
112118
"styles": [
113-
"src/styles.css"
119+
"src/app/shared/styles/_tokens.css",
120+
"src/styles.css",
121+
"src/app/shared/styles/_utilities.css",
122+
"src/app/shared/styles/_components.css"
114123
],
115124
"scripts": []
116125
}

dashboard/e2e/smoke.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
const email = process.env.E2E_EMAIL || 'admin@onevo.local';
4+
const password = process.env.E2E_PASSWORD || 'Admin123!';
5+
6+
async function login(page: import('@playwright/test').Page): Promise<void> {
7+
await page.goto('/login');
8+
await page.getByLabel('Email').fill(email);
9+
await page.getByLabel('Password').fill(password);
10+
await page.getByRole('button', { name: 'Sign in' }).click();
11+
await expect(page).toHaveURL(/\/app\/alerts/);
12+
await expect(page.getByRole('heading', { name: 'Alerts' })).toBeVisible();
13+
}
14+
15+
test.describe('onetix dashboard smoke', () => {
16+
test('login lands on alerts', async ({ page }) => {
17+
await login(page);
18+
});
19+
20+
test('alerts list loads and opens alert review', async ({ page }) => {
21+
await login(page);
22+
const reviewBtn = page.getByRole('button', { name: 'Review' }).first();
23+
const hasAlerts = await reviewBtn.isVisible().catch(() => false);
24+
test.skip(!hasAlerts, 'No alerts in seeded data');
25+
26+
await reviewBtn.click();
27+
await expect(page).toHaveURL(/\/app\/alerts\/.+/);
28+
await expect(page.getByRole('heading', { name: 'Review' })).toBeVisible();
29+
await page.getByRole('button', { name: 'Submit review' }).click();
30+
await expect(page.getByText('Review saved.')).toBeVisible({ timeout: 15_000 });
31+
});
32+
33+
test('store filter updates query param', async ({ page }) => {
34+
await login(page);
35+
const storeSelect = page.getByLabel('Store filter');
36+
const optionCount = await storeSelect.locator('option').count();
37+
test.skip(optionCount < 2, 'Need at least one store besides All stores');
38+
39+
const firstStoreValue = await storeSelect.locator('option').nth(1).getAttribute('value');
40+
const firstStoreLabel = await storeSelect.locator('option').nth(1).textContent();
41+
test.skip(!firstStoreValue, 'No store option value');
42+
43+
await storeSelect.selectOption(firstStoreValue!);
44+
await expect(page).toHaveURL(new RegExp(`storeId=${firstStoreValue}`));
45+
await expect(storeSelect).toHaveValue(firstStoreValue!);
46+
if (firstStoreLabel) {
47+
await expect(storeSelect.locator('option:checked')).toHaveText(firstStoreLabel.trim());
48+
}
49+
});
50+
});
51+
52+
test.describe('mobile shell', () => {
53+
test.use({ viewport: { width: 390, height: 844 } });
54+
55+
test('drawer opens and closes', async ({ page }) => {
56+
await login(page);
57+
const menuBtn = page.getByRole('button', { name: 'Toggle menu' });
58+
await menuBtn.click();
59+
await expect(page.locator('.sidebar.open')).toBeVisible();
60+
await expect(menuBtn).toHaveAttribute('aria-expanded', 'true');
61+
62+
await page.locator('.sidebar-backdrop.open').click();
63+
await expect(page.locator('.sidebar.open')).toHaveCount(0);
64+
await expect(menuBtn).toHaveAttribute('aria-expanded', 'false');
65+
});
66+
});

dashboard/package-lock.json

Lines changed: 87 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dashboard/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
"start": "ng serve",
77
"build": "ng build",
88
"watch": "ng build --watch --configuration development",
9-
"test": "ng test"
9+
"test": "ng test",
10+
"e2e": "playwright test",
11+
"e2e:ui": "playwright test --ui"
1012
},
1113
"private": true,
1214
"dependencies": {
@@ -17,6 +19,7 @@
1719
"@angular/platform-browser": "^19.2.0",
1820
"@angular/platform-browser-dynamic": "^19.2.0",
1921
"@angular/router": "^19.2.0",
22+
"chart.js": "^4.5.1",
2023
"rxjs": "~7.8.0",
2124
"tslib": "^2.3.0",
2225
"zone.js": "~0.15.0"
@@ -25,7 +28,9 @@
2528
"@angular-devkit/build-angular": "^19.2.27",
2629
"@angular/cli": "^19.2.27",
2730
"@angular/compiler-cli": "^19.2.0",
31+
"@playwright/test": "^1.52.0",
2832
"@types/jasmine": "~5.1.0",
33+
"@types/node": "^26.1.2",
2934
"jasmine-core": "~5.6.0",
3035
"karma": "~6.4.0",
3136
"karma-chrome-launcher": "~3.2.0",

dashboard/playwright.config.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './e2e',
5+
fullyParallel: false,
6+
forbidOnly: !!process.env.CI,
7+
retries: process.env.CI ? 1 : 0,
8+
workers: 1,
9+
reporter: 'list',
10+
timeout: 60_000,
11+
use: {
12+
baseURL: process.env.E2E_BASE_URL || 'http://localhost:4200',
13+
trace: 'on-first-retry',
14+
},
15+
projects: [
16+
{
17+
name: 'chromium',
18+
use: { ...devices['Desktop Chrome'] },
19+
},
20+
{
21+
name: 'mobile',
22+
use: { ...devices['Pixel 5'] },
23+
},
24+
],
25+
webServer: process.env.E2E_SKIP_WEBSERVER
26+
? undefined
27+
: {
28+
command: 'npm run start',
29+
url: 'http://localhost:4200',
30+
reuseExistingServer: !process.env.CI,
31+
timeout: 120_000,
32+
},
33+
});

dashboard/proxy.conf.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"/api": {
3+
"target": "http://localhost:8080",
4+
"secure": false,
5+
"changeOrigin": true
6+
}
7+
}

dashboard/src/app/app.component.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,31 @@
1-
import { Component } from '@angular/core';
2-
import { RouterOutlet } from '@angular/router';
1+
import { Component, OnInit, OnDestroy } from '@angular/core';
2+
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
3+
import { filter, Subscription } from 'rxjs';
34

45
@Component({
56
selector: 'app-root',
67
standalone: true,
78
imports: [RouterOutlet],
89
template: `<router-outlet></router-outlet>`,
910
})
10-
export class AppComponent {}
11+
export class AppComponent implements OnInit, OnDestroy {
12+
private sub?: Subscription;
13+
14+
constructor(private router: Router) {}
15+
16+
ngOnInit(): void {
17+
this.applyBodyClass(this.router.url);
18+
this.sub = this.router.events
19+
.pipe(filter((e) => e instanceof NavigationEnd))
20+
.subscribe((e) => this.applyBodyClass((e as NavigationEnd).urlAfterRedirects));
21+
}
22+
23+
ngOnDestroy(): void {
24+
this.sub?.unsubscribe();
25+
}
26+
27+
private applyBodyClass(url: string): void {
28+
const locked = url.startsWith('/app');
29+
document.body.classList.toggle('shell-locked', locked);
30+
}
31+
}

0 commit comments

Comments
 (0)