From e2f2a5725992d810d8fba8c361e324dfabb45e37 Mon Sep 17 00:00:00 2001
From: Anubhav Singh
{statusAnnouncement}
XLM / Stellar Assets]
+ end
+
+ subgraph Off-chain Services
+ IPFS[IPFS Gateway]
+ Sentry[Sentry Error Tracking]
+ end
+
+ subgraph Monitoring Stack
+ Prom[Prometheus]
+ Graf[Grafana]
+ AM[Alertmanager]
+ end
+
+ Client <--> FE
+ Freelancer <--> FE
+ FE <--> FW
+ FW -- sign tx --> FE
+ FE -- HTTP --> RPC
+ RPC -- invoke --> SC
+ SC -- transfer --> TC
+ FE -- store metadata --> IPFS
+ FE -- error reports --> Sentry
+ FE -- POST /api/metrics --> Prom
+ Prom --> Graf
+ Prom --> AM
+```
+
+## Component Architecture
+
+### Smart Contract Layer
+
+The platform's on-chain logic lives in two Soroban contracts written in Rust:
+
+#### Escrow Contract (`contracts/escrow/`)
+
+The primary contract managing the full job lifecycle.
+
+| Category | Functions | Purpose |
+|----------|-----------|---------|
+| **Lifecycle** | `post_job`, `accept_job`, `submit_work`, `approve_work`, `cancel_job`, `enforce_deadline`, `mutual_cancel` | Core job state machine transitions |
+| **Milestones** | `create_job_with_milestones`, `approve_milestone`, `complete_milestone`, `get_milestones` | Multi-milestone escrow with per-milestone payment release |
+| **Disputes** | `raise_dispute`, `resolve_dispute`, `resolve_dispute_split`, `batch_resolve_disputes` | Conflict resolution with configurable split payouts |
+| **Fee Management** | `update_fee_bps`, `get_fee_bps`, `withdraw_fees`, `set_discount_tiers`, `calculate_effective_fee_bps` | Platform fee configuration with volume-based discounts |
+| **Access Control** | `set_whitelist_mode`, `add_to_whitelist`, `add_to_blacklist`, `set_fee_exemption` | Whitelist/blacklist gating and fee exemptions |
+| **Visibility** | `set_job_visibility`, `add_invited_freelancer`, `get_job_visibility` | Public, Private, and InviteOnly job visibility modes |
+| **Attestations** | `get_attestation`, `get_user_attestations` | On-chain work completion attestations |
+| **Referrals** | `register_referral`, `post_job_with_referral`, `get_referral_earnings`, `withdraw_referral_earnings` | Referral code system with earn-and-withdraw flow |
+| **SLA** | `post_job_with_sla`, `get_sla_status` | Service-level agreements with penalty enforcement |
+| **Admin** | `admin_get_all_jobs`, `get_dashboard_stats`, `set_paused`, `propose_upgrade` | Administrative oversight and contract governance |
+| **Gasless** | `set_trusted_forwarder`, `relay_cancel_job` | Meta-transaction support via trusted forwarders |
+| **Payment Splits** | `approve_with_splits`, `set_payment_splits`, `get_available_splits` | Multi-recipient payout splitting |
+| **Templates** | (saved via `Template` / `TemplateCount` storage keys) | Reusable job configurations per client |
+
+**Storage Model:**
+
+Soroban provides two storage tiers used by the contract:
+
+- **Instance storage** — configuration and counters that live with the contract instance: `Admin`, `NativeToken`, `FeeBps`, `JobsCount`, `Paused`, `Fees`, `TotalVolume`, `UniqueClients`, `UniqueFreelancers`, `DiscountTiers`, `AllowedTokenCount`
+- **Persistent storage** — per-entity data with independent TTL management: `Job(id)`, `AllowedToken(addr)`, `Milestone(job_id, idx)`, `SLAConfig(job_id)`, `Attestation(job_id)`, `ReferralEarnings(addr)`, `Blacklisted(addr)`, `Whitelisted(addr)`, `FreelancerJobs(addr)`, `ClientJobs(addr)`, `PaymentSplit(job_id, idx)`
+
+TTL is actively bumped: active jobs get `518_400` ledger extensions (~30 days), archived jobs get `120_960` (~7 days).
+
+#### Retainer Contract (`contracts/retainer/`)
+
+Manages recurring retainer agreements and cross-chain job portability:
+
+- `create_retainer`, `renew_retainer`, `cancel_retainer` — periodic payment agreements
+- `export_job`, `import_job` — cross-chain job migration
+- `set_rate_limit`, `set_trusted_address` — rate limiting and trust management
+
+### Frontend Layer
-## Data Split: On-chain vs Off-chain
+Built with **Next.js 16** (App Router) and **React 19**, deployed to Vercel.
-To optimize costs and performance, data is strategically split:
+```mermaid
+graph LR
+ subgraph Pages["Next.js App Router Pages"]
+ Home["/ (Home)"]
+ Dashboard["/dashboard"]
+ PostJob["/post-job"]
+ JobDetail["/job/[id]"]
+ Profile["/profile/[address]"]
+ Admin["/admin"]
+ Disputes["/disputes"]
+ Messages["/messages"]
+ Meetings["/meetings"]
+ Settings["/settings"]
+ Transactions["/transactions"]
+ end
-- **On-chain Data (Soroban Contract)**: Contains only the critical financial and state data necessary for trustless execution. This includes the `Job` struct containing IDs, statuses, wallet addresses of the employer and freelancer, and escrow amounts.
-- **Off-chain Data (localStorage / Backend)**: Contains heavy metadata that does not require on-chain consensus, such as detailed job descriptions, titles, images, and user profiles. The frontend matches off-chain metadata with on-chain IDs.
+ subgraph Components["Key Components"]
+ Sidebar
+ WalletSelector
+ StatusPill
+ JobFilterPanel
+ MilestoneProgress
+ RichTextEditor
+ NotificationInbox
+ CommandPalette
+ CallOverlay
+ end
-## Wallet Integration (Freighter)
+ subgraph Lib["Library Modules"]
+ StellarLib["stellar.ts
Wallet + RPC"]
+ ContractLib["contract.ts
Contract wrappers"]
+ ConfigLib["config.ts
Env validation"]
+ MetricsLib["metrics.ts
Prometheus registry"]
+ MetricsClient["metrics-client.ts
Browser beacon"]
+ NetworkConfig["network-config.ts
Network switching"]
+ end
-Wallet connectivity is handled via the Freighter wallet (`@stellar/freighter-api`).
+ subgraph Contexts["State Management (React Context)"]
+ WalletCtx["WalletProvider"]
+ NetworkCtx["NetworkProvider"]
+ NotifCtx["NotificationProvider"]
+ MsgCtx["MessagingProvider"]
+ MeetCtx["MeetingsProvider"]
+ end
-1. **Connection**: Users connect their Freighter wallet to authenticate.
-2. **Transaction Signing**: When a user performs an on-chain action (e.g., funding a job), the frontend constructs the transaction and prompts the user to sign it via the Freighter extension.
-3. **Network Submission**: The signed transaction is then submitted to the Soroban RPC.
+ Home --> Components
+ Dashboard --> Components
+ JobDetail --> Components
+ Components --> Lib
+ Components --> Contexts
+ Lib --> StellarLib
+ StellarLib --> ContractLib
+```
-## Job & Escrow Lifecycle
+**State management** uses React Context providers — no external state library:
-The core mechanism of the platform relies on a smart contract Escrow.
+| Provider | Scope |
+|----------|-------|
+| `WalletProvider` | Wallet connection, account switching, balance, legal consent |
+| `NetworkProvider` | Stellar network selection (testnet/futurenet/mainnet) |
+| `NotificationProvider` | In-app notification queue |
+| `MessagingProvider` | Direct messaging state |
+| `MeetingsProvider` | Video call state |
+| `TypographyProvider` | Font size preferences |
-1. **Post**: An employer creates a job and deposits funds into the contract's escrow.
-2. **Accept**: A freelancer accepts the job, updating the on-chain state to link their address.
-3. **Submit**: The freelancer submits their completed work.
-4. **Approve/Cancel**: The employer reviews the work. If approved, funds are released to the freelancer. If cancelled/disputed, funds can be returned to the employer (depending on dispute resolution rules).
+### Stellar Integration
-### Flow Diagram
+The frontend communicates with the Stellar network through Soroban RPC:
```mermaid
sequenceDiagram
- actor Employer
- actor Freelancer
+ participant User
participant Frontend
participant Freighter
- participant SorobanContract
-
- Employer->>Frontend: Post Job & Deposit Funds
- Frontend->>Freighter: Request Signature
- Freighter-->>Frontend: Signed Tx
- Frontend->>SorobanContract: Submit Tx (Post Job)
- SorobanContract-->>Frontend: Job Created (Escrow Locked)
-
- Freelancer->>Frontend: Accept Job
- Frontend->>Freighter: Request Signature
- Freighter-->>Frontend: Signed Tx
- Frontend->>SorobanContract: Submit Tx (Accept Job)
- SorobanContract-->>Frontend: Job Assigned
-
- Freelancer->>Frontend: Submit Work
- Frontend->>Freighter: Request Signature
- Freighter-->>Frontend: Signed Tx
- Frontend->>SorobanContract: Submit Tx (Submit Work)
- SorobanContract-->>Frontend: Status: Pending Approval
-
- Employer->>Frontend: Approve Work
- Frontend->>Freighter: Request Signature
- Freighter-->>Frontend: Signed Tx
- Frontend->>SorobanContract: Submit Tx (Approve Work)
- SorobanContract-->>Frontend: Escrow Released to Freelancer
+ participant RPC as Soroban RPC
+ participant Network as Stellar Network
+ participant Contract as Escrow Contract
+
+ User->>Frontend: Click "Post Job"
+ Frontend->>Frontend: Build transaction
(Contract.post_job)
+ Frontend->>Freighter: Request signature
+ Freighter->>User: Show approval dialog
+ User->>Freighter: Approve
+ Freighter-->>Frontend: Signed XDR
+ Frontend->>RPC: sendTransaction
+ RPC->>Network: Submit to ledger
+ Network-->>RPC: Transaction result
+ RPC-->>Frontend: Poll getTransaction
+ Frontend-->>User: Show success + tx link
+```
+
+**RPC interaction patterns:**
+
+- **Transaction building** — `@stellar/stellar-sdk` constructs `TransactionBuilder` with `Contract` invocation
+- **Signing** — Freighter extension signs the XDR envelope (`@stellar/freighter-api`)
+- **Submission** — `rpc.Server.sendTransaction()` followed by polling `getTransaction()` with exponential backoff (3 retries: 1s, 2s, 4s)
+- **Read-only calls** — `simulateTransaction()` for view functions, no signing required
+- **Event polling** — Soroban events published by the contract are queryable via `getEvents()`
+
+## Data Flow Diagrams
+
+### Job Posting Flow
+
+```mermaid
+sequenceDiagram
+ actor Client
+ participant FE as Frontend
+ participant FW as Freighter
+ participant RPC as Soroban RPC
+ participant SC as Escrow Contract
+ participant Token as Token Contract
+
+ Client->>FE: Fill job form + deposit amount
+ FE->>FE: Hash description (SHA-256)
+ FE->>FE: Store description on IPFS
+ FE->>FE: Build post_job transaction
+ FE->>FW: Request signature
+ FW-->>FE: Signed transaction
+ FE->>RPC: sendTransaction
+ RPC->>SC: Invoke post_job
+ SC->>Token: Transfer deposit from client to contract
+ SC->>SC: Store Job struct, increment JobsCount
+ SC-->>RPC: Emit job_created event
+ RPC-->>FE: Transaction SUCCESS
+ FE-->>Client: Show job created confirmation
+```
+
+### Job Completion & Payment Release Flow
+
+```mermaid
+sequenceDiagram
+ actor Freelancer
+ actor Client
+ participant FE as Frontend
+ participant FW as Freighter
+ participant SC as Escrow Contract
+ participant Token as Token Contract
+
+ Freelancer->>FE: Click "Submit Work"
+ FE->>FW: Sign submit_work tx
+ FW-->>FE: Signed tx
+ FE->>SC: submit_work(job_id)
+ SC->>SC: Status → SubmittedForReview
+
+ Client->>FE: Review submission
+ Client->>FE: Click "Approve Work"
+ FE->>FW: Sign approve_work tx
+ FW-->>FE: Signed tx
+ FE->>SC: approve_work(job_id)
+ SC->>SC: Calculate platform fee (2.5% base)
+ SC->>SC: Apply SLA penalty if breached
+ SC->>SC: Apply volume discount if eligible
+ SC->>Token: Transfer payout to freelancer
+ SC->>SC: Status → Completed
+ SC->>SC: Increment CompletedJobsCount
+ SC->>SC: Store attestation
+ SC-->>FE: Emit work_approved event
+ FE-->>Client: Show completion confirmation
+ FE-->>Freelancer: Show payment received
```
+
+### Dispute Resolution Flow
+
+```mermaid
+sequenceDiagram
+ actor Freelancer
+ actor Client
+ actor Admin
+ participant SC as Escrow Contract
+ participant Token as Token Contract
+
+ Freelancer->>SC: raise_dispute(job_id)
+ Note over SC: Status → Disputed
Freelancer deposits dispute fee
+
+ Admin->>SC: resolve_dispute(job_id, client_bps)
+ Note over SC: client_bps determines split
0 = freelancer wins all
10000 = client wins all
+
+ SC->>Token: Transfer client share
+ SC->>Token: Transfer freelancer share
+ SC->>Token: Refund dispute deposit to raiser
+ SC->>SC: Status → Completed
+```
+
+## Deployment Architecture
+
+### Development
+
+```mermaid
+graph LR
+ subgraph Docker Compose
+ Stellar["stellar
(Stellar Quickstart)"]
+ Frontend["frontend
(Next.js dev server)"]
+ Builder["contract-builder
(Rust toolchain)"]
+ end
+
+ subgraph Monitoring
+ Prometheus
+ Grafana
+ Alertmanager
+ end
+
+ Frontend -- localhost:3000 --> Stellar
+ Frontend -- /api/metrics --> Prometheus
+ Prometheus --> Grafana
+ Prometheus --> Alertmanager
+```
+
+Local development uses Docker Compose with three services:
+- **stellar** — Stellar Quickstart image with Soroban RPC on port 8000
+- **frontend** — Next.js dev server on port 3000
+- **contract-builder** — Rust/Soroban CLI for building and testing contracts
+
+Monitoring stack (`monitoring/docker-compose.monitoring.yml`) adds Prometheus, Grafana, Alertmanager, Blackbox Exporter, and Node Exporter.
+
+### Production
+
+```mermaid
+graph TB
+ subgraph CDN["Edge / CDN"]
+ Vercel[Vercel Edge Network]
+ end
+
+ subgraph Frontend["Frontend (Vercel)"]
+ NextApp[Next.js App]
+ MetricsAPI["/api/metrics"]
+ end
+
+ subgraph Stellar["Stellar Network"]
+ MainnetRPC[Soroban RPC]
+ MainnetContract[Escrow Contract]
+ end
+
+ subgraph Monitoring["Monitoring (Kubernetes)"]
+ Prom[Prometheus]
+ Graf[Grafana]
+ AM[Alertmanager]
+ end
+
+ subgraph Infra["Infrastructure (Terraform + Helm)"]
+ K8s[Kubernetes Cluster]
+ Ingress[Ingress Controller]
+ end
+
+ Users --> Vercel
+ Vercel --> NextApp
+ NextApp --> MainnetRPC
+ MainnetRPC --> MainnetContract
+ MetricsAPI --> Prom
+ Prom --> Graf
+ Prom --> AM
+ K8s --> Ingress
+ Ingress --> NextApp
+```
+
+**Production deployment:**
+- **Frontend** — Deployed to Vercel with automatic preview deployments on PRs
+- **Smart contracts** — Deployed to Stellar mainnet via Soroban CLI; verified on StellarExpert
+- **Infrastructure** — Provisioned with Terraform (AWS), orchestrated with Helm charts on Kubernetes
+- **Monitoring** — Prometheus scrapes `/api/metrics` every 30s, Grafana dashboards auto-provisioned, Alertmanager routes to Slack
+
+## Tech Stack
+
+| Layer | Technology | Rationale |
+|-------|-----------|-----------|
+| **Smart Contracts** | Rust + Soroban SDK 21.7 | Stellar's native WASM contract runtime; Rust provides memory safety and performance |
+| **Frontend Framework** | Next.js 16 (App Router) | SSR/SSG, API routes, built-in image optimization, Vercel-native |
+| **UI** | React 19 + Tailwind CSS 4 | Component model, concurrent features, utility-first styling |
+| **Wallet** | Freighter (`@stellar/freighter-api`) | Official Stellar browser wallet; also supports WalletConnect and Ledger |
+| **Stellar SDK** | `@stellar/stellar-sdk` 15.x | Transaction building, RPC client, XDR encoding |
+| **Rich Text** | TipTap 3.27 | Extensible editor built on ProseMirror; supports links, placeholders |
+| **Icons** | Lucide React | Tree-shakeable, consistent icon set |
+| **i18n** | next-intl 4.x | Type-safe internationalization with App Router support |
+| **Testing** | Vitest + Testing Library + Playwright | Unit tests, component tests, and E2E browser tests |
+| **Contract Testing** | Soroban testutils + proptest + cargo-fuzz | Snapshot tests, property-based tests, fuzz testing |
+| **Monitoring** | Prometheus + Grafana + Alertmanager | Industry-standard observability stack |
+| **Error Tracking** | Sentry | Real-time error monitoring with source maps |
+| **CI/CD** | GitHub Actions | Automated lint, typecheck, build, test, deploy pipeline |
+| **Deployment** | Vercel (frontend) + Terraform + Helm (infra) | Git-driven deploys with infrastructure as code |
+| **Containerization** | Docker + Docker Compose | Consistent local development environment |
+| **Security** | CodeQL + pre-commit hooks | Static analysis and commit-time checks |
+
+## Key Design Decisions
+
+### On-chain vs Off-chain Data Split
+
+To optimize cost and performance, data is strategically split:
+
+| Data | Location | Reason |
+|------|----------|--------|
+| Job state (status, amounts, addresses) | On-chain (Soroban) | Trustless escrow requires authoritative on-chain state |
+| Job descriptions, titles, images | Off-chain (IPFS + localStorage) | Large payloads are expensive on-chain; IPFS provides content addressing |
+| User profiles, skills, testimonials | Off-chain (localStorage) | Personal data doesn't need consensus |
+| Notifications, messages | Off-chain (browser state) | Ephemeral communication data |
+| Metrics, telemetry | Off-chain (in-memory + Prometheus) | Operational data for monitoring |
+
+### Fee Architecture
+
+The platform charges a base fee of **2.5% (250 bps)** on every completed job:
+
+- **Volume discounts** — Configurable `DiscountTier` table reduces fees for high-volume freelancers
+- **SLA penalties** — Late delivery can incur additional deductions based on `SLAConfig.penalty_bps`
+- **Fee exemptions** — Admin can exempt specific addresses from fees
+- **Dispute deposits** — 5 XLM default deposit to discourage frivolous disputes; refunded to the raiser
+
+### Contract Upgradeability
+
+The contract supports a governed upgrade path:
+
+1. Admin calls `propose_upgrade(wasm_hash)` — starts a 24-hour timelock
+2. After timelock expires, admin calls `execute_upgrade()` — replaces WASM
+3. Admin can `cancel_upgrade()` at any time before execution
+
+This prevents surprise contract changes and gives users time to react.
diff --git a/docs/MONITORING.md b/docs/MONITORING.md
index 4ae28a2..99fb6f5 100644
--- a/docs/MONITORING.md
+++ b/docs/MONITORING.md
@@ -59,6 +59,11 @@ browser ──POST /api/metrics──▶ Next.js server (in-memory registry)
| `stellarwork_contract_tx_duration_milliseconds` | histogram | `method`, `network` | End-to-end invocation latency |
| `stellarwork_rpc_errors_total` | counter | `kind`, `network` | Stellar RPC failures by coarse kind |
| `stellarwork_client_errors_total` | counter | `kind`, `path` | Unhandled frontend errors |
+| `stellarwork_http_requests_total` | counter | `route`, `status` | HTTP requests by route and status code |
+| `stellarwork_http_request_duration_milliseconds` | histogram | `route` | HTTP request processing latency |
+| `stellarwork_http_errors_total` | counter | `route` | HTTP responses with 4xx/5xx status |
+| `stellarwork_active_sessions_total` | counter | `type` | Beacon pings (proxy for concurrent visitors) |
+| `stellarwork_job_views_total` | counter | `job_id` | Job detail page views |
Label values are sanitized and each metric is capped at 500 distinct series, so
browser-supplied labels cannot grow memory without bound.
@@ -93,6 +98,8 @@ Defined in [`monitoring/prometheus/alerts.yml`](../monitoring/prometheus/alerts.
| `LcpRegression` | warning | p75 LCP >2.5s for 30m |
| `InpRegression` | warning | p75 INP >200ms for 30m |
| `ClientErrorSpike` | warning | >1 client error/sec for 10m |
+| `HttpErrorRateHigh` | warning | >1% HTTP 4xx/5xx responses for 10m |
+| `HttpLatencyHigh` | warning | p95 HTTP latency >3s for 15m |
Routing lives in [`monitoring/alertmanager/alertmanager.yml`](../monitoring/alertmanager/alertmanager.yml).
Alertmanager does **not** expand environment variables — replace the placeholder Slack
diff --git a/frontend/__tests__/cancel-job-confirm.test.tsx b/frontend/__tests__/cancel-job-confirm.test.tsx
index f82abc2..f235d9d 100644
--- a/frontend/__tests__/cancel-job-confirm.test.tsx
+++ b/frontend/__tests__/cancel-job-confirm.test.tsx
@@ -56,7 +56,6 @@ vi.mock("@/lib/meetings-context", () => ({
cancelMeeting: vi.fn(),
completeMeeting: vi.fn(),
rescheduleProposal: vi.fn(),
- cancelMeeting: vi.fn(),
confirmMeeting: vi.fn(),
getMeetingsForJob: () => [],
getUpcomingMeetings: () => [],
@@ -76,6 +75,7 @@ function makeJob(overrides: Partial
{countdown.text}
{job.status === "SubmittedForReview" && (() => { const countdown = getAutoApprovalCountdown(job.submitted_at); @@ -621,6 +631,13 @@ function JobDetailPageContent() {
Status:
- {job.token
- ? `${job.token.slice(0, 8)}...${job.token.slice(-4)}`
- : "N/A"}
{job.token ? (
) : (
@@ -794,27 +808,6 @@ function JobDetailPageContent() {
})()}
{/* Schedule meeting form */}
- {showScheduleForm && wallet && (() => {
- const otherParty =
- wallet === job.client ? job.freelancer :
- wallet === job.freelancer ? job.client :
- job.client;
- if (!otherParty) return null;
- return (
-
- Propose a Meeting
-
-
-
- setMeetingTitle(e.target.value)}
- placeholder="e.g. Project kickoff call"
- className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-xs focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
- />
-
-
{showScheduleForm &&
wallet &&
(() => {
@@ -909,23 +902,6 @@ function JobDetailPageContent() {
})()}
{/* Show existing meetings for this job */}
- {wallet && (() => {
- const jobMeetings = getMeetingsForJob(numericId);
- if (jobMeetings.length === 0) return null;
- return (
-
- Meetings
- {jobMeetings.map((m) => (
-
-
- {m.title}
-
- {m.status}
-
{wallet &&
(() => {
const jobMeetings = getMeetingsForJob(numericId);
@@ -1073,6 +1049,7 @@ function JobDetailPageContent() {
)}
+ >
)}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index dd1fb92..6caac65 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -3,7 +3,6 @@ import { Geist, Geist_Mono } from "next/font/google";
import Link from "next/link";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
-import dynamic from "next/dynamic";
import { WalletProvider } from "@/lib/wallet-context";
import { ToastProvider } from "@/components/ToastProvider";
import { NotificationProvider } from "@/lib/notifications-context";
@@ -19,17 +18,9 @@ import JsonLd from "@/components/JsonLd";
import AppFooter from "@/components/AppFooter";
import OfflineIndicator from "@/components/OfflineIndicator";
import DeferredClientFeatures from "@/components/DeferredClientFeatures";
+import { ClientProviders } from "./client-providers";
import "./globals.css";
-const CommandPalette = dynamic(() => import("@/components/CommandPalette"), { ssr: false });
-const ShortcutCheatSheet = dynamic(() => import("@/components/ShortcutCheatSheet"), { ssr: false });
-const OnboardingProvider = dynamic(() => import("@/components/OnboardingProvider"), { ssr: false });
-const InstallPrompt = dynamic(() => import("@/components/InstallPrompt"), { ssr: false });
-const ServiceWorkerRegistration = dynamic(() => import("@/components/ServiceWorkerRegistration"), { ssr: false });
-const AnnouncementBanner = dynamic(() => import("@/components/AnnouncementBanner"), { ssr: false });
-const MetricsReporter = dynamic(() => import("@/components/MetricsReporter"), { ssr: false });
-const Sidebar = dynamic(() => import("@/components/Sidebar"), { ssr: false });
-
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
@@ -156,11 +147,8 @@ export default async function RootLayout({
Skip to main content
-
-
-
+
-
@@ -193,6 +181,7 @@ export default async function RootLayout({
+
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index fac5e1a..f3ff3d2 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -216,8 +216,6 @@ export default function HomePage() {
sessionStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode);
}, [viewMode]);
- const totalPages = useMemo(() => Math.max(1, Math.ceil(totalJobs / pageSize)), [pageSize, totalJobs]);
-
useEffect(() => {
try {
const stored = localStorage.getItem(BOOKMARK_STORAGE_KEY);
@@ -1023,7 +1021,7 @@ export default function HomePage() {
const deadline = formatDeadline(job.deadline);
// ✅ FIX: Check if the connected wallet is the job owner
- const isOwnJob = wallet && wallet.address && job.client && wallet.address === job.client;
+ const isOwnJob = wallet && job.client && wallet === job.client;
return (
@@ -1084,9 +1082,6 @@ export default function HomePage() {
markJobViewed(id)}>
Job #{id}
- {newJobIds.has(id) && (
-
- {job.title || `Job #${id}`}
{newJobIds.has(id) && (
)}
+ {/* ── Completion Certificates ─────────────────────────────────────────── */}
+ {!loading && certificates.length > 0 && (
+
+ Completion Certificates
+ {certificates.length} on-chain proof{certificates.length !== 1 ? "s" : ""} of completed work
+
+ {certificates.map((cert, idx) => (
+
+
+
+
+ Job #{cert.job_id}
+
+
+
+ Client:
+ Amount: {toXlm(cert.amount)} XLM
+ Completed: ledger {cert.completed_at}
+
+
+ ))}
+
+
+ )}
+
{/* ── Job History ────────────────────────────────────────────────────── */}
{!loading && (
diff --git a/frontend/components/MetricsReporter.tsx b/frontend/components/MetricsReporter.tsx
index 7086ad1..a2bcf28 100644
--- a/frontend/components/MetricsReporter.tsx
+++ b/frontend/components/MetricsReporter.tsx
@@ -17,6 +17,16 @@ export default function MetricsReporter() {
reportSample({ type: "page_view", path: pathname });
}, [pathname]);
+ useEffect(() => {
+ reportSample({ type: "session_ping" });
+ const interval = setInterval(() => {
+ if (document.visibilityState === "visible") {
+ reportSample({ type: "session_ping" });
+ }
+ }, 30_000);
+ return () => clearInterval(interval);
+ }, []);
+
useEffect(() => {
if (typeof PerformanceObserver === "undefined") return;
diff --git a/frontend/components/PullToRefresh.tsx b/frontend/components/PullToRefresh.tsx
index 01a65dc..73f8d5b 100644
--- a/frontend/components/PullToRefresh.tsx
+++ b/frontend/components/PullToRefresh.tsx
@@ -50,7 +50,7 @@ export default function PullToRefresh({ onRefresh, disabled = false, label = "Re
className="pointer-events-none fixed left-1/2 top-0 z-40 flex -translate-x-1/2 justify-center"
style={{
transform: `translate3d(-50%, ${offset}px, 0)`,
- opacity: visible ? Math.max(progress, phase === "idle" ? 0 : 0.4) : 0,
+ opacity: visible ? Math.max(progress, 0.4) : 0,
transition: distance === 0 ? "transform 200ms ease-out, opacity 200ms ease-out" : "opacity 120ms linear",
}}
>
diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx
index 985fea3..9c3d60c 100644
--- a/frontend/components/Sidebar.tsx
+++ b/frontend/components/Sidebar.tsx
@@ -17,7 +17,7 @@ import {
ChevronRight,
Menu,
} from "lucide-react";
-import { useState, useCallback } from "react";
+import { useState, useCallback, useEffect } from "react";
const SIDEBAR_STATE_KEY = "stellarwork:sidebar-collapsed";
const SIDEBAR_WIDTH_EXPANDED = 220;
diff --git a/frontend/lib/contract.ts b/frontend/lib/contract.ts
index d393c02..696108d 100644
--- a/frontend/lib/contract.ts
+++ b/frontend/lib/contract.ts
@@ -592,3 +592,61 @@ export async function migrateJobVersion(
]);
return Number(response.data ?? targetVersion);
}
+
+// ─── Job View Counter ────────────────────────────────────────────────────────
+
+export async function recordJobView(viewer: string, jobId: string) {
+ return callContract(getActiveContractId(), "record_job_view", [
+ nativeToScVal(viewer, { type: "address" }),
+ nativeToScVal(jobId, { type: "u64" }),
+ ]);
+}
+
+export async function getJobViews(jobId: string): Promise {
+ const response = await callContract(
+ getActiveContractId(),
+ "get_job_views",
+ [nativeToScVal(jobId, { type: "u64" })],
+ { readOnly: true },
+ );
+ return Number(response.data ?? 0);
+}
+
+// ─── Completion Certificates ─────────────────────────────────────────────────
+
+export interface CompletionCertificate {
+ job_id: number;
+ client: string;
+ freelancer: string;
+ amount: string;
+ completed_at: string;
+ metadata_uri: string;
+}
+
+export async function getCertificates(
+ freelancer: string,
+ start: number,
+ limit: number,
+): Promise {
+ const response = await callContract(
+ getActiveContractId(),
+ "get_certificates",
+ [
+ nativeToScVal(freelancer, { type: "address" }),
+ nativeToScVal(start, { type: "u64" }),
+ nativeToScVal(limit, { type: "u64" }),
+ ],
+ { readOnly: true },
+ );
+ return (response.data as CompletionCertificate[]) ?? [];
+}
+
+export async function getCertificateCount(freelancer: string): Promise {
+ const response = await callContract(
+ getActiveContractId(),
+ "get_certificate_count",
+ [nativeToScVal(freelancer, { type: "address" })],
+ { readOnly: true },
+ );
+ return Number(response.data ?? 0);
+}
diff --git a/frontend/lib/job-views.ts b/frontend/lib/job-views.ts
new file mode 100644
index 0000000..9bf5ad8
--- /dev/null
+++ b/frontend/lib/job-views.ts
@@ -0,0 +1,35 @@
+const VIEW_STORAGE_PREFIX = "stellarwork:viewed:";
+
+function getStorageKey(jobId: string, wallet: string): string {
+ return `${VIEW_STORAGE_PREFIX}${wallet}:${jobId}`;
+}
+
+function getSessionKey(jobId: string): string {
+ return `stellarwork:session-view:${jobId}`;
+}
+
+export function hasViewedToday(jobId: string, wallet: string): boolean {
+ if (typeof window === "undefined") return true;
+ const key = getStorageKey(jobId, wallet);
+ const stored = localStorage.getItem(key);
+ if (!stored) return false;
+ const timestamp = Number(stored);
+ const oneDayMs = 24 * 60 * 60 * 1000;
+ return Date.now() - timestamp < oneDayMs;
+}
+
+export function markViewed(jobId: string, wallet: string): void {
+ if (typeof window === "undefined") return;
+ const key = getStorageKey(jobId, wallet);
+ localStorage.setItem(key, String(Date.now()));
+}
+
+export function hasViewedThisSession(jobId: string): boolean {
+ if (typeof window === "undefined") return true;
+ return sessionStorage.getItem(getSessionKey(jobId)) === "1";
+}
+
+export function markSessionViewed(jobId: string): void {
+ if (typeof window === "undefined") return;
+ sessionStorage.setItem(getSessionKey(jobId), "1");
+}
diff --git a/frontend/lib/metrics-client.ts b/frontend/lib/metrics-client.ts
index ac8e12e..e01d708 100644
--- a/frontend/lib/metrics-client.ts
+++ b/frontend/lib/metrics-client.ts
@@ -23,7 +23,9 @@ export type MetricSample =
durationMs?: number;
}
| { type: "rpc_error"; kind: string; network: string }
- | { type: "client_error"; kind: string; path: string };
+ | { type: "client_error"; kind: string; path: string }
+ | { type: "job_view"; jobId: string }
+ | { type: "session_ping" };
let queue: MetricSample[] = [];
let timer: ReturnType | null = null;
@@ -84,6 +86,14 @@ export function reportRpcError(kind: string, network: string) {
reportSample({ type: "rpc_error", kind, network });
}
+export function reportJobView(jobId: string) {
+ reportSample({ type: "job_view", jobId });
+}
+
+export function reportSessionPing() {
+ reportSample({ type: "session_ping" });
+}
+
/** Buckets a thrown value into a coarse, low-cardinality error kind. */
export function classifyError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
diff --git a/frontend/lib/metrics.ts b/frontend/lib/metrics.ts
index e31022b..ce84e43 100644
--- a/frontend/lib/metrics.ts
+++ b/frontend/lib/metrics.ts
@@ -123,6 +123,32 @@ const clientErrors = counter(
"Unhandled frontend errors reported by browsers.",
);
+const httpRequests = counter(
+ "stellarwork_http_requests_total",
+ "HTTP requests handled by the Next.js server, by route and status code.",
+);
+
+const httpRequestDuration = histogram(
+ "stellarwork_http_request_duration_milliseconds",
+ "HTTP request processing latency in milliseconds, by route.",
+ [10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000],
+);
+
+const httpErrors = counter(
+ "stellarwork_http_errors_total",
+ "HTTP responses with 4xx or 5xx status codes, by route.",
+);
+
+const activeSessions = counter(
+ "stellarwork_active_sessions_total",
+ "Number of beacon requests received (proxy for concurrent visitors).",
+);
+
+const jobViews = counter(
+ "stellarwork_job_views_total",
+ "Job detail page views reported by browsers.",
+);
+
// ── recording API ───────────────────────────────────────────────────────────
/** Keeps label values low-cardinality and safe to render in the exposition format. */
@@ -168,6 +194,24 @@ export function recordClientError(kind: string, path: string) {
incCounter(clientErrors, { kind: sanitizeLabel(kind), path: sanitizeLabel(path, "/") });
}
+export function recordHttpRequest(route: string, statusCode: number, durationMs: number) {
+ const routeLabel = sanitizeLabel(route, "/");
+ const statusLabel = String(statusCode);
+ incCounter(httpRequests, { route: routeLabel, status: statusLabel });
+ observe(httpRequestDuration, { route: routeLabel }, durationMs);
+ if (statusCode >= 400) {
+ incCounter(httpErrors, { route: routeLabel });
+ }
+}
+
+export function recordActiveSession() {
+ incCounter(activeSessions, { type: "beacon" });
+}
+
+export function recordJobView(jobId: string) {
+ incCounter(jobViews, { job_id: sanitizeLabel(jobId) });
+}
+
/** Test hook — drops every recorded sample. */
export function resetMetrics() {
for (const metric of registry.values()) metric.series.clear();
diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts
index c348f6a..bf5d0ec 100644
--- a/frontend/lib/stellar.ts
+++ b/frontend/lib/stellar.ts
@@ -296,26 +296,6 @@ async function invokeContract(
throw new Error("Connect Freighter before calling contract.");
}
const account = await server.getAccount(source);
- if (sent.hash) {
- recordRecentContractInteraction({
- hash: sent.hash,
- status: "PENDING",
- timestamp: Date.now(),
- method,
- });
- }
-
- if (sent.status === "ERROR") {
- if (sent.hash) {
- recordRecentContractInteraction({
- hash: sent.hash,
- status: "ERROR",
- timestamp: Date.now(),
- method,
- });
- }
- throw new Error(sent.errorResult?.toXDR().toString() ?? "Contract invocation failed.");
- }
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
@@ -336,31 +316,25 @@ async function invokeContract(
const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase);
const sent = await server.sendTransaction(signedTx);
- if (sent.status === "ERROR") {
- throw new Error(sent.errorResult?.toXDR().toString() ?? "Contract invocation failed.");
- if (status.status === rpc.Api.GetTransactionStatus.SUCCESS) {
- recordRecentContractInteraction({
- hash: sent.hash,
- status: "SUCCESS",
- timestamp: Date.now(),
- method,
- });
- return { status: "SUCCESS", hash: sent.hash };
- }
+ if (sent.hash) {
+ recordRecentContractInteraction({
+ hash: sent.hash,
+ status: "PENDING",
+ timestamp: Date.now(),
+ method,
+ });
+ }
- if (status.status === rpc.Api.GetTransactionStatus.FAILED) {
+ if (sent.status === "ERROR") {
+ if (sent.hash) {
recordRecentContractInteraction({
hash: sent.hash,
status: "ERROR",
timestamp: Date.now(),
method,
});
- return {
- status: "ERROR",
- hash: sent.hash,
- errorResult: "Transaction failed.",
- };
}
+ throw new Error(sent.errorResult?.toXDR().toString() ?? "Contract invocation failed.");
}
if (sent.status === "PENDING") {
@@ -373,10 +347,22 @@ async function invokeContract(
const status = await server.getTransaction(sent.hash);
if (status.status === rpc.Api.GetTransactionStatus.SUCCESS) {
+ recordRecentContractInteraction({
+ hash: sent.hash,
+ status: "SUCCESS",
+ timestamp: Date.now(),
+ method,
+ });
return { status: "SUCCESS", hash: sent.hash } as TransactionResult;
}
if (status.status === rpc.Api.GetTransactionStatus.FAILED) {
+ recordRecentContractInteraction({
+ hash: sent.hash,
+ status: "ERROR",
+ timestamp: Date.now(),
+ method,
+ });
return {
status: "ERROR",
hash: sent.hash,
@@ -391,17 +377,7 @@ async function invokeContract(
}
return { status: "SUCCESS", hash: sent.hash } as TransactionResult;
- }, operationLabel);
- if (sent.hash) {
- recordRecentContractInteraction({
- hash: sent.hash,
- status: "SUCCESS",
- timestamp: Date.now(),
- method,
- });
- }
-
- return { status: "SUCCESS", hash: sent.hash };
+ }, method);
}
export function decodeScVal(value: xdr.ScVal): T {
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index 52eed52..63c6f1a 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -42,8 +42,6 @@ export interface Job {
submitted_at: string;
title?: string;
category?: string;
- /** Unix timestamp the freelancer last submitted work, when the contract exposes it. */
- submitted_at?: string;
}
/** A single milestone within a milestone-based job. */
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index 66e5a23..b9e5d1e 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,6 +1,5 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
-import { withSentryConfig } from "@sentry/nextjs";
import withBundleAnalyzer from "@next/bundle-analyzer";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
@@ -29,15 +28,6 @@ const nextConfig: NextConfig = {
],
};
-export default withSentryConfig(withNextIntl(nextConfig), {
- org: process.env.SENTRY_ORG,
- project: process.env.SENTRY_PROJECT,
- silent: true,
- widenClientFileUpload: true,
- hideSourceMaps: true,
- disableLogger: true,
- automaticVercelMonitors: false,
-});
export default withBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
})(withNextIntl(nextConfig));
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3cf302d..c99f9a3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14257,6 +14257,111 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz",
+ "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz",
+ "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz",
+ "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz",
+ "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz",
+ "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz",
+ "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.2.3",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz",
+ "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
}
}
}
diff --git a/frontend/sentry.client.config.ts b/frontend/sentry.client.config.ts
index faf54a3..cb0ff5c 100644
--- a/frontend/sentry.client.config.ts
+++ b/frontend/sentry.client.config.ts
@@ -1,16 +1 @@
-import * as Sentry from "@sentry/nextjs";
-
-Sentry.init({
- dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
- environment: process.env.NEXT_PUBLIC_DEPLOY_ENV ?? "development",
- tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0,
- replaysOnErrorSampleRate: 1.0,
- replaysSessionSampleRate: 0.05,
- integrations: [
- Sentry.replayIntegration({
- maskAllText: true,
- blockAllMedia: true,
- }),
- ],
- enabled: process.env.NODE_ENV === "production",
-});
+export {};
diff --git a/frontend/sentry.edge.config.ts b/frontend/sentry.edge.config.ts
index 0829632..cb0ff5c 100644
--- a/frontend/sentry.edge.config.ts
+++ b/frontend/sentry.edge.config.ts
@@ -1,8 +1 @@
-import * as Sentry from "@sentry/nextjs";
-
-Sentry.init({
- dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
- environment: process.env.NEXT_PUBLIC_DEPLOY_ENV ?? "development",
- tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0,
- enabled: process.env.NODE_ENV === "production",
-});
+export {};
diff --git a/frontend/sentry.server.config.ts b/frontend/sentry.server.config.ts
index 0829632..cb0ff5c 100644
--- a/frontend/sentry.server.config.ts
+++ b/frontend/sentry.server.config.ts
@@ -1,8 +1 @@
-import * as Sentry from "@sentry/nextjs";
-
-Sentry.init({
- dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
- environment: process.env.NEXT_PUBLIC_DEPLOY_ENV ?? "development",
- tracesSampleRate: process.env.NODE_ENV === "production" ? 0.2 : 1.0,
- enabled: process.env.NODE_ENV === "production",
-});
+export {};
diff --git a/monitoring/grafana/dashboards/stellarwork-overview.json b/monitoring/grafana/dashboards/stellarwork-overview.json
index f1b53fd..d012caa 100644
--- a/monitoring/grafana/dashboards/stellarwork-overview.json
+++ b/monitoring/grafana/dashboards/stellarwork-overview.json
@@ -389,6 +389,165 @@
"legendFormat": "{{path}}"
}
]
+ },
+ {
+ "id": 12,
+ "type": "timeseries",
+ "title": "HTTP requests by route",
+ "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 36 },
+ "options": {
+ "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] },
+ "tooltip": { "mode": "multi", "sort": "desc" }
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "reqps",
+ "custom": { "drawStyle": "line", "fillOpacity": 15, "lineWidth": 2, "stacking": { "mode": "normal" } }
+ },
+ "overrides": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" },
+ "expr": "sum by (route) (rate(stellarwork_http_requests_total{instance=~\"$instance\"}[$__rate_interval]))",
+ "legendFormat": "{{route}}"
+ }
+ ]
+ },
+ {
+ "id": 13,
+ "type": "timeseries",
+ "title": "HTTP request latency (p95)",
+ "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 36 },
+ "options": {
+ "legend": { "displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"] },
+ "tooltip": { "mode": "multi", "sort": "desc" }
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "custom": { "drawStyle": "line", "fillOpacity": 0, "lineWidth": 2 },
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "red", "value": 3000 }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" },
+ "expr": "histogram_quantile(0.95, sum by (le, route) (rate(stellarwork_http_request_duration_milliseconds_bucket{instance=~\"$instance\"}[$__rate_interval])))",
+ "legendFormat": "{{route}}"
+ }
+ ]
+ },
+ {
+ "id": 14,
+ "type": "stat",
+ "title": "HTTP error rate (1h)",
+ "description": "Percentage of HTTP responses with 4xx/5xx status codes.",
+ "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" },
+ "gridPos": { "h": 4, "w": 6, "x": 0, "y": 44 },
+ "options": {
+ "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
+ "colorMode": "background",
+ "graphMode": "area",
+ "textMode": "auto"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percentunit",
+ "decimals": 2,
+ "min": 0,
+ "max": 1,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "green", "value": null },
+ { "color": "orange", "value": 0.01 },
+ { "color": "red", "value": 0.05 }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" },
+ "expr": "sum(rate(stellarwork_http_errors_total{instance=~\"$instance\"}[1h])) / clamp_min(sum(rate(stellarwork_http_requests_total{instance=~\"$instance\"}[1h])), 0.001)",
+ "legendFormat": "error rate"
+ }
+ ]
+ },
+ {
+ "id": 15,
+ "type": "stat",
+ "title": "Active sessions (beacon)",
+ "description": "Approximate concurrent visitors based on beacon pings.",
+ "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" },
+ "gridPos": { "h": 4, "w": 6, "x": 6, "y": 44 },
+ "options": {
+ "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
+ "colorMode": "background",
+ "graphMode": "area",
+ "textMode": "auto"
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "decimals": 0,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ { "color": "blue", "value": null }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" },
+ "expr": "sum(increase(stellarwork_active_sessions_total{instance=~\"$instance\"}[5m]))",
+ "legendFormat": "sessions"
+ }
+ ]
+ },
+ {
+ "id": 16,
+ "type": "timeseries",
+ "title": "Job views by job ID",
+ "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 44 },
+ "options": {
+ "legend": { "displayMode": "table", "placement": "right", "calcs": ["sum", "max"] },
+ "tooltip": { "mode": "multi", "sort": "desc" }
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "custom": { "drawStyle": "bars", "fillOpacity": 60, "lineWidth": 1, "stacking": { "mode": "normal" } }
+ },
+ "overrides": []
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": { "type": "prometheus", "uid": "stellarwork-prometheus" },
+ "expr": "topk(10, sum by (job_id) (increase(stellarwork_job_views_total{instance=~\"$instance\"}[$__rate_interval])))",
+ "legendFormat": "job {{job_id}}"
+ }
+ ]
}
]
}
diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml
index 3444342..f582579 100644
--- a/monitoring/prometheus/alerts.yml
+++ b/monitoring/prometheus/alerts.yml
@@ -111,3 +111,38 @@ groups:
description: >-
{{ $value | printf "%.2f" }} client errors/sec over the last 5
minutes. Check the error breakdown panel by kind and path.
+
+ - name: stellarwork-http-health
+ rules:
+ - alert: HttpErrorRateHigh
+ expr: |
+ (
+ sum(rate(stellarwork_http_errors_total[10m]))
+ /
+ clamp_min(sum(rate(stellarwork_http_requests_total[10m])), 0.001)
+ ) > 0.01
+ for: 10m
+ labels:
+ severity: warning
+ team: platform
+ annotations:
+ summary: HTTP error rate above 1%
+ description: >-
+ More than 1% of HTTP responses returned 4xx/5xx status codes over
+ the last 10 minutes. Check the HTTP errors panel by route.
+
+ - alert: HttpLatencyHigh
+ expr: |
+ histogram_quantile(
+ 0.95,
+ sum by (le) (rate(stellarwork_http_request_duration_milliseconds_bucket[10m]))
+ ) > 3000
+ for: 15m
+ labels:
+ severity: warning
+ team: platform
+ annotations:
+ summary: p95 HTTP request latency above 3s
+ description: >-
+ Server-side HTTP request processing at p95 exceeds 3 seconds.
+ Investigate slow API routes or upstream Stellar RPC latency.