Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.

Commit 080c371

Browse files
authored
Merge pull request #151 from rohitdash08/grafana
add grafana and pods
2 parents eca234e + 9909634 commit 080c371

43 files changed

Lines changed: 3546 additions & 418 deletions

Some content is hidden

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

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,18 @@ finmind/
143143
- Backend: Dockerized Flask to Railway/Render free tier (Postgres & Redis managed or via Compose locally).
144144
- Frontend: Vercel.
145145
- Secrets: use environment variables (.env locally, platform secrets in cloud).
146+
- Kubernetes manifests for full stack deployment are available in `deploy/k8s/`.
146147

147148
## Local Development
148149
1) Prereqs: Docker, Docker Compose, Node 20+, Python 3.11+
149150
2) Copy env: `cp .env.example .env` and fill secrets
150151
3) Start: `docker compose up --build`
151152
4) Frontend at http://localhost:5173, Backend at http://localhost:8000
153+
5) Observability stack (dev compose) is included:
154+
- Grafana: http://localhost:3000
155+
- Prometheus: http://localhost:9090
156+
- Loki: http://localhost:3100
157+
- Nginx proxy: http://localhost:8080 (status at `/nginx_status`)
152158

153159
### Backend Test Runner (No local pytest setup required)
154160
- PowerShell (Windows):
@@ -162,6 +168,14 @@ finmind/
162168
- Backend: pytest, flake8, black. Frontend: vitest, eslint.
163169
- GitHub Actions `ci.yml` runs lint, tests, and builds both apps; optional docker build.
164170

171+
## Monitoring (Grafana OSS)
172+
- Backend exposes Prometheus metrics at `/metrics` with:
173+
- request count by endpoint/status
174+
- request duration histograms (latency, including dashboard p95 KPI)
175+
- reminder event counters (engagement KPI)
176+
- Logs are emitted as JSON with `request_id` and shipped to Loki via Promtail.
177+
- Pre-provisioned Grafana dashboard: `FinMind Operations and KPI`.
178+
165179
## Contribution Policy
166180
- See `CONTRIBUTING.md` for fork-first contribution flow and PR requirements.
167181

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React from 'react';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { Analytics } from '@/pages/Analytics';
5+
6+
jest.mock('@/components/ui/button', () => ({
7+
Button: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
8+
<button {...props}>{children}</button>
9+
),
10+
}));
11+
jest.mock('@/components/ui/input', () => ({
12+
Input: ({ ...props }: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
13+
}));
14+
jest.mock('@/components/ui/label', () => ({
15+
Label: ({ children, ...props }: React.PropsWithChildren & React.LabelHTMLAttributes<HTMLLabelElement>) => (
16+
<label {...props}>{children}</label>
17+
),
18+
}));
19+
jest.mock('@/components/ui/financial-card', () => ({
20+
FinancialCard: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
21+
FinancialCardHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
22+
FinancialCardContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
23+
FinancialCardTitle: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
24+
FinancialCardDescription: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
25+
}));
26+
27+
const toastMock = jest.fn();
28+
jest.mock('@/hooks/use-toast', () => ({
29+
useToast: () => ({ toast: toastMock }),
30+
}));
31+
32+
const getBudgetSuggestionMock = jest.fn();
33+
jest.mock('@/api/insights', () => ({
34+
getBudgetSuggestion: (...args: unknown[]) => getBudgetSuggestionMock(...args),
35+
}));
36+
37+
describe('Analytics integration', () => {
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
getBudgetSuggestionMock.mockResolvedValue({
41+
month: '2026-02',
42+
suggested_total: 1200,
43+
breakdown: { needs: 600, wants: 360, savings: 240 },
44+
tips: ['Tip A', 'Tip B'],
45+
analytics: {
46+
month_over_month_change_pct: 12.5,
47+
current_month_expenses: 1000,
48+
previous_month_expenses: 888.89,
49+
top_categories: [],
50+
},
51+
persona: 'Balanced coach',
52+
method: 'heuristic',
53+
warnings: [],
54+
});
55+
});
56+
57+
it('loads and renders insights data', async () => {
58+
render(<Analytics />);
59+
await waitFor(() => expect(getBudgetSuggestionMock).toHaveBeenCalled());
60+
expect(screen.getByText(/live spending analytics/i)).toBeInTheDocument();
61+
expect(screen.getByText(/suggested budget/i)).toBeInTheDocument();
62+
expect(screen.getByText(/tip a/i)).toBeInTheDocument();
63+
});
64+
65+
it('refreshes insights with month/persona/key controls', async () => {
66+
render(<Analytics />);
67+
await waitFor(() => expect(getBudgetSuggestionMock).toHaveBeenCalledTimes(1));
68+
69+
await userEvent.clear(screen.getByLabelText(/analytics month/i));
70+
await userEvent.type(screen.getByLabelText(/analytics month/i), '2026-01');
71+
await userEvent.selectOptions(screen.getByLabelText(/analytics persona/i), 'Debt-focused planner');
72+
await userEvent.type(screen.getByLabelText(/gemini api key/i), 'abc123');
73+
await userEvent.click(screen.getByRole('button', { name: /refresh insights/i }));
74+
75+
await waitFor(() =>
76+
expect(getBudgetSuggestionMock).toHaveBeenLastCalledWith(
77+
expect.objectContaining({
78+
month: '2026-01',
79+
persona: 'Debt-focused planner',
80+
geminiApiKey: 'abc123',
81+
}),
82+
),
83+
);
84+
});
85+
});

app/src/__tests__/Expenses.integration.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,19 @@ const updateExpenseMock = jest.fn();
5656
const deleteExpenseMock = jest.fn();
5757
const previewImportMock = jest.fn();
5858
const commitImportMock = jest.fn();
59+
const listRecurringMock = jest.fn();
60+
const createRecurringMock = jest.fn();
61+
const generateRecurringMock = jest.fn();
5962
jest.mock('@/api/expenses', () => ({
6063
listExpenses: (...args: unknown[]) => listExpensesMock(...args),
6164
createExpense: (...args: unknown[]) => createExpenseMock(...args),
6265
updateExpense: (...args: unknown[]) => updateExpenseMock(...args),
6366
deleteExpense: (...args: unknown[]) => deleteExpenseMock(...args),
6467
previewExpenseImport: (...args: unknown[]) => previewImportMock(...args),
6568
commitExpenseImport: (...args: unknown[]) => commitImportMock(...args),
69+
listRecurringExpenses: (...args: unknown[]) => listRecurringMock(...args),
70+
createRecurringExpense: (...args: unknown[]) => createRecurringMock(...args),
71+
generateRecurringExpenses: (...args: unknown[]) => generateRecurringMock(...args),
6672
}));
6773

6874
const listCategoriesMock = jest.fn();
@@ -88,6 +94,20 @@ describe('Expenses page integration', () => {
8894
transactions: [{ date: '2026-02-10', amount: 14.2, description: 'Taxi', category_id: null }],
8995
});
9096
commitImportMock.mockResolvedValue({ inserted: 1, duplicates: 0 });
97+
listRecurringMock.mockResolvedValue([]);
98+
createRecurringMock.mockResolvedValue({
99+
id: 77,
100+
amount: 100,
101+
currency: 'INR',
102+
expense_type: 'EXPENSE',
103+
category_id: null,
104+
description: 'Gym',
105+
cadence: 'MONTHLY',
106+
start_date: '2026-02-01',
107+
end_date: null,
108+
active: true,
109+
});
110+
generateRecurringMock.mockResolvedValue({ inserted: 1 });
91111
});
92112

93113
it('creates expense from quick add form', async () => {
@@ -127,4 +147,22 @@ describe('Expenses page integration', () => {
127147
]),
128148
));
129149
});
150+
151+
it('creates recurring expense from recurring form', async () => {
152+
render(<Expenses />);
153+
await waitFor(() => expect(listExpensesMock).toHaveBeenCalled());
154+
155+
await userEvent.type(screen.getByLabelText(/recurring amount/i), '100');
156+
await userEvent.type(screen.getByLabelText(/recurring description/i), 'Gym');
157+
await userEvent.click(screen.getByRole('button', { name: /add recurring/i }));
158+
159+
await waitFor(() => expect(createRecurringMock).toHaveBeenCalled());
160+
expect(createRecurringMock).toHaveBeenCalledWith(
161+
expect.objectContaining({
162+
amount: 100,
163+
description: 'Gym',
164+
cadence: 'MONTHLY',
165+
}),
166+
);
167+
});
130168
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React from 'react';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import Reminders from '@/pages/Reminders';
5+
6+
const toastMock = jest.fn();
7+
jest.mock('@/hooks/use-toast', () => ({
8+
useToast: () => ({ toast: toastMock }),
9+
}));
10+
11+
jest.mock('@/components/ui/button', () => ({
12+
Button: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes<HTMLButtonElement>) => (
13+
<button {...props}>{children}</button>
14+
),
15+
}));
16+
jest.mock('@/components/ui/input', () => ({
17+
Input: ({ ...props }: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
18+
}));
19+
jest.mock('@/components/ui/label', () => ({
20+
Label: ({ children, ...props }: React.PropsWithChildren & React.LabelHTMLAttributes<HTMLLabelElement>) => (
21+
<label {...props}>{children}</label>
22+
),
23+
}));
24+
jest.mock('@/components/ui/dialog', () => ({
25+
Dialog: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
26+
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
27+
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
28+
DialogTitle: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
29+
DialogDescription: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
30+
DialogTrigger: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
31+
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
32+
}));
33+
jest.mock('@/components/ui/alert-dailog', () => ({
34+
AlertDialog: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
35+
AlertDialogTrigger: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
36+
AlertDialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
37+
AlertDialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
38+
AlertDialogTitle: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
39+
AlertDialogDescription: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
40+
AlertDialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
41+
AlertDialogCancel: ({ children }: React.PropsWithChildren) => <button>{children}</button>,
42+
AlertDialogAction: ({ children, ...props }: React.PropsWithChildren & React.ButtonHTMLAttributes<HTMLButtonElement>) => <button {...props}>{children}</button>,
43+
}));
44+
45+
const listRemindersMock = jest.fn();
46+
const createReminderMock = jest.fn();
47+
const deleteReminderMock = jest.fn();
48+
const runDueMock = jest.fn();
49+
const scheduleBillRemindersMock = jest.fn();
50+
const reportAutopayResultMock = jest.fn();
51+
jest.mock('@/api/reminders', () => ({
52+
listReminders: (...args: unknown[]) => listRemindersMock(...args),
53+
createReminder: (...args: unknown[]) => createReminderMock(...args),
54+
deleteReminder: (...args: unknown[]) => deleteReminderMock(...args),
55+
runDue: (...args: unknown[]) => runDueMock(...args),
56+
scheduleBillReminders: (...args: unknown[]) => scheduleBillRemindersMock(...args),
57+
reportAutopayResult: (...args: unknown[]) => reportAutopayResultMock(...args),
58+
}));
59+
60+
const listBillsMock = jest.fn();
61+
jest.mock('@/api/bills', () => ({
62+
listBills: (...args: unknown[]) => listBillsMock(...args),
63+
}));
64+
65+
describe('Reminders integration', () => {
66+
beforeEach(() => {
67+
jest.clearAllMocks();
68+
listRemindersMock.mockResolvedValue([]);
69+
listBillsMock.mockResolvedValue([
70+
{ id: 1, name: 'Electricity', autopay_enabled: true, channel_email: true, channel_whatsapp: true },
71+
]);
72+
scheduleBillRemindersMock.mockResolvedValue({ created: 6 });
73+
reportAutopayResultMock.mockResolvedValue({ created: 2 });
74+
createReminderMock.mockResolvedValue({ id: 10 });
75+
deleteReminderMock.mockResolvedValue({});
76+
runDueMock.mockResolvedValue({ processed: 0 });
77+
});
78+
79+
it('schedules bill reminders from bill scheduler panel', async () => {
80+
render(<Reminders />);
81+
await waitFor(() => expect(listBillsMock).toHaveBeenCalled());
82+
await screen.findByText(/smart bill scheduling/i);
83+
84+
await userEvent.type(screen.getByLabelText(/reminder offsets/i), '7,3,1');
85+
await userEvent.click(screen.getByRole('button', { name: /schedule bill reminders/i }));
86+
87+
await waitFor(() => expect(scheduleBillRemindersMock).toHaveBeenCalled());
88+
expect(scheduleBillRemindersMock).toHaveBeenCalledWith(1, [7, 3, 1]);
89+
});
90+
91+
it('sends autopay result follow-up', async () => {
92+
render(<Reminders />);
93+
await waitFor(() => expect(listBillsMock).toHaveBeenCalled());
94+
await screen.findByText(/smart bill scheduling/i);
95+
96+
await userEvent.click(screen.getByRole('button', { name: /autopay success/i }));
97+
await waitFor(() => expect(reportAutopayResultMock).toHaveBeenCalledWith(1, 'SUCCESS'));
98+
});
99+
});

app/src/api/bills.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type Bill = {
77
currency?: string;
88
next_due_date?: string; // YYYY-MM-DD
99
cadence?: 'WEEKLY' | 'MONTHLY' | 'YEARLY' | 'ONCE';
10+
autopay_enabled?: boolean;
1011
channel_email?: boolean;
1112
channel_whatsapp?: boolean;
1213
paid_at?: string | null;

app/src/api/expenses.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,30 @@ export type ImportPreviewResponse = {
3232
transactions: ImportTransaction[];
3333
};
3434

35+
export type RecurringExpense = {
36+
id: number;
37+
amount: number;
38+
currency: string;
39+
expense_type: string;
40+
category_id: number | null;
41+
description: string;
42+
cadence: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
43+
start_date: string;
44+
end_date: string | null;
45+
active: boolean;
46+
};
47+
48+
export type RecurringExpenseCreate = {
49+
amount: number;
50+
description: string;
51+
category_id?: number | null;
52+
cadence: 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
53+
start_date: string;
54+
end_date?: string | null;
55+
expense_type?: 'EXPENSE' | 'INCOME';
56+
currency?: string;
57+
};
58+
3559
export async function listExpenses(params?: {
3660
from?: string;
3761
to?: string;
@@ -93,3 +117,23 @@ export async function commitExpenseImport(
93117
body: { transactions },
94118
});
95119
}
120+
121+
export async function listRecurringExpenses(): Promise<RecurringExpense[]> {
122+
return api<RecurringExpense[]>('/expenses/recurring');
123+
}
124+
125+
export async function createRecurringExpense(
126+
payload: RecurringExpenseCreate,
127+
): Promise<RecurringExpense> {
128+
return api<RecurringExpense>('/expenses/recurring', { method: 'POST', body: payload });
129+
}
130+
131+
export async function generateRecurringExpenses(
132+
recurringId: number,
133+
throughDate: string,
134+
): Promise<{ inserted: number }> {
135+
return api<{ inserted: number }>(`/expenses/recurring/${recurringId}/generate`, {
136+
method: 'POST',
137+
body: { through_date: throughDate },
138+
});
139+
}

app/src/api/insights.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { api } from './client';
2+
3+
export type BudgetSuggestion = {
4+
month: string;
5+
suggested_total: number;
6+
breakdown: {
7+
needs: number;
8+
wants: number;
9+
savings: number;
10+
};
11+
tips?: string[];
12+
analytics: {
13+
month_over_month_change_pct: number;
14+
current_month_expenses: number;
15+
previous_month_expenses: number;
16+
top_categories: Array<{ category_id: string; amount: number }>;
17+
};
18+
persona?: string;
19+
method: 'gemini' | 'heuristic' | string;
20+
warnings?: string[];
21+
net_flow?: number;
22+
};
23+
24+
export async function getBudgetSuggestion(params?: {
25+
month?: string;
26+
geminiApiKey?: string;
27+
persona?: string;
28+
}): Promise<BudgetSuggestion> {
29+
const monthQuery = params?.month ? `?month=${encodeURIComponent(params.month)}` : '';
30+
const headers: Record<string, string> = {};
31+
if (params?.geminiApiKey) headers['X-Gemini-Api-Key'] = params.geminiApiKey;
32+
if (params?.persona) headers['X-Insight-Persona'] = params.persona;
33+
return api<BudgetSuggestion>(`/insights/budget-suggestion${monthQuery}`, { headers });
34+
}

0 commit comments

Comments
 (0)