duoops 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ {
2
+ "metadata": {
3
+ "generatedAt": "2025-06-14T16:09:07.455Z",
4
+ "methodology": "Real-time Electricity Maps API with industry standard PUE",
5
+ "availableZones": [
6
+ "US-CENT-SWPP"
7
+ ]
8
+ },
9
+ "regions": {
10
+ "us-central1": {
11
+ "zone": "US-CENT-SWPP",
12
+ "industryStandardPUE": 1.1,
13
+ "realTimeCarbonIntensity": 492,
14
+ "googleOfficialIntensity": 430,
15
+ "cfe": 0.95
16
+ },
17
+ "us-east1": {
18
+ "zone": "FALLBACK",
19
+ "industryStandardPUE": 1.1,
20
+ "error": "No matching zone found in available zones"
21
+ },
22
+ "us-east4": {
23
+ "zone": "FALLBACK",
24
+ "industryStandardPUE": 1.1,
25
+ "error": "No matching zone found in available zones"
26
+ },
27
+ "us-west1": {
28
+ "zone": "FALLBACK",
29
+ "industryStandardPUE": 1.1,
30
+ "error": "No matching zone found in available zones"
31
+ },
32
+ "us-west2": {
33
+ "zone": "FALLBACK",
34
+ "industryStandardPUE": 1.1,
35
+ "error": "No matching zone found in available zones"
36
+ },
37
+ "us-west3": {
38
+ "zone": "FALLBACK",
39
+ "industryStandardPUE": 1.1,
40
+ "error": "No matching zone found in available zones"
41
+ },
42
+ "us-west4": {
43
+ "zone": "FALLBACK",
44
+ "industryStandardPUE": 1.1,
45
+ "error": "No matching zone found in available zones"
46
+ },
47
+ "us-south1": {
48
+ "zone": "FALLBACK",
49
+ "industryStandardPUE": 1.1,
50
+ "error": "No matching zone found in available zones"
51
+ },
52
+ "europe-west1": {
53
+ "zone": "FALLBACK",
54
+ "industryStandardPUE": 1.1,
55
+ "error": "No matching zone found in available zones"
56
+ },
57
+ "europe-west2": {
58
+ "zone": "FALLBACK",
59
+ "industryStandardPUE": 1.1,
60
+ "error": "No matching zone found in available zones"
61
+ },
62
+ "europe-west3": {
63
+ "zone": "FALLBACK",
64
+ "industryStandardPUE": 1.1,
65
+ "error": "No matching zone found in available zones"
66
+ },
67
+ "europe-west4": {
68
+ "zone": "FALLBACK",
69
+ "industryStandardPUE": 1.1,
70
+ "error": "No matching zone found in available zones"
71
+ },
72
+ "europe-west6": {
73
+ "zone": "FALLBACK",
74
+ "industryStandardPUE": 1.1,
75
+ "error": "No matching zone found in available zones"
76
+ },
77
+ "europe-west8": {
78
+ "zone": "FALLBACK",
79
+ "industryStandardPUE": 1.1,
80
+ "error": "No matching zone found in available zones"
81
+ },
82
+ "europe-west9": {
83
+ "zone": "FALLBACK",
84
+ "industryStandardPUE": 1.1,
85
+ "error": "No matching zone found in available zones"
86
+ },
87
+ "europe-west10": {
88
+ "zone": "FALLBACK",
89
+ "industryStandardPUE": 1.1,
90
+ "error": "No matching zone found in available zones"
91
+ },
92
+ "europe-west12": {
93
+ "zone": "FALLBACK",
94
+ "industryStandardPUE": 1.1,
95
+ "error": "No matching zone found in available zones"
96
+ },
97
+ "europe-north1": {
98
+ "zone": "FALLBACK",
99
+ "industryStandardPUE": 1.1,
100
+ "error": "No matching zone found in available zones"
101
+ },
102
+ "europe-central2": {
103
+ "zone": "FALLBACK",
104
+ "industryStandardPUE": 1.1,
105
+ "error": "No matching zone found in available zones"
106
+ },
107
+ "europe-southwest1": {
108
+ "zone": "FALLBACK",
109
+ "industryStandardPUE": 1.1,
110
+ "error": "No matching zone found in available zones"
111
+ },
112
+ "asia-east1": {
113
+ "zone": "FALLBACK",
114
+ "industryStandardPUE": 1.1,
115
+ "error": "No matching zone found in available zones"
116
+ },
117
+ "asia-east2": {
118
+ "zone": "FALLBACK",
119
+ "industryStandardPUE": 1.1,
120
+ "error": "No matching zone found in available zones"
121
+ },
122
+ "asia-northeast1": {
123
+ "zone": "FALLBACK",
124
+ "industryStandardPUE": 1.1,
125
+ "error": "No matching zone found in available zones"
126
+ },
127
+ "asia-northeast2": {
128
+ "zone": "FALLBACK",
129
+ "industryStandardPUE": 1.1,
130
+ "error": "No matching zone found in available zones"
131
+ },
132
+ "asia-northeast3": {
133
+ "zone": "FALLBACK",
134
+ "industryStandardPUE": 1.1,
135
+ "error": "No matching zone found in available zones"
136
+ },
137
+ "asia-south1": {
138
+ "zone": "FALLBACK",
139
+ "industryStandardPUE": 1.1,
140
+ "error": "No matching zone found in available zones"
141
+ },
142
+ "asia-south2": {
143
+ "zone": "FALLBACK",
144
+ "industryStandardPUE": 1.1,
145
+ "error": "No matching zone found in available zones"
146
+ },
147
+ "asia-southeast1": {
148
+ "zone": "FALLBACK",
149
+ "industryStandardPUE": 1.1,
150
+ "error": "No matching zone found in available zones"
151
+ },
152
+ "asia-southeast2": {
153
+ "zone": "FALLBACK",
154
+ "industryStandardPUE": 1.1,
155
+ "error": "No matching zone found in available zones"
156
+ },
157
+ "australia-southeast1": {
158
+ "zone": "FALLBACK",
159
+ "industryStandardPUE": 1.1,
160
+ "error": "No matching zone found in available zones"
161
+ },
162
+ "australia-southeast2": {
163
+ "zone": "FALLBACK",
164
+ "industryStandardPUE": 1.1,
165
+ "error": "No matching zone found in available zones"
166
+ },
167
+ "southamerica-east1": {
168
+ "zone": "FALLBACK",
169
+ "industryStandardPUE": 1.1,
170
+ "error": "No matching zone found in available zones"
171
+ },
172
+ "northamerica-northeast1": {
173
+ "zone": "FALLBACK",
174
+ "industryStandardPUE": 1.1,
175
+ "error": "No matching zone found in available zones"
176
+ },
177
+ "northamerica-northeast2": {
178
+ "zone": "FALLBACK",
179
+ "industryStandardPUE": 1.1,
180
+ "error": "No matching zone found in available zones"
181
+ }
182
+ }
183
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@duoops/mcp-server",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for DuoOps carbon metrics",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "start": "node dist/index.js",
10
+ "dev": "tsx watch src/index.ts",
11
+ "lint": "eslint ."
12
+ },
13
+ "dependencies": {
14
+ "@google-cloud/bigquery": "^8.1.1",
15
+ "@modelcontextprotocol/sdk": "^1.27.1",
16
+ "axios": "^1.7.9",
17
+ "express": "^4.21.2",
18
+ "zod": "^3.24.2"
19
+ },
20
+ "devDependencies": {
21
+ "@types/express": "^5.0.0",
22
+ "@types/node": "^22.10.2",
23
+ "eslint": "^9.17.0",
24
+ "tsx": "^4.19.2",
25
+ "typescript": "^5.7.2"
26
+ },
27
+ "engines": {
28
+ "node": ">=22.0.0"
29
+ }
30
+ }
@@ -0,0 +1,73 @@
1
+ /* eslint-disable camelcase */
2
+ import axios from 'axios'
3
+ import {NextFunction, Request, Response} from 'express'
4
+
5
+ export const GITLAB_URL = process.env.GITLAB_URL || 'https://gitlab.com'
6
+
7
+ export async function validateGitLabToken(token: string): Promise<boolean> {
8
+ try {
9
+ const response = await axios.get(`${GITLAB_URL}/api/v4/user`, {
10
+ headers: {
11
+ Authorization: `Bearer ${token}`,
12
+ },
13
+ })
14
+ return response.status === 200
15
+ } catch (error) {
16
+ console.error('GitLab token validation failed:', error)
17
+ return false
18
+ }
19
+ }
20
+
21
+ export function authMiddleware(req: Request, res: Response, next: NextFunction) {
22
+ // Skip auth for well-known discovery endpoints
23
+ if (req.path.startsWith('/.well-known/')) {
24
+ return next()
25
+ }
26
+
27
+ // Check for Authorization header
28
+ const authHeader = req.headers.authorization
29
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
30
+ return sendUnauthorized(res)
31
+ }
32
+
33
+ const token = authHeader.split(' ')[1]
34
+
35
+ validateGitLabToken(token)
36
+ .then((isValid) => {
37
+ if (isValid) {
38
+ next()
39
+ } else {
40
+ sendUnauthorized(res)
41
+ }
42
+ })
43
+ .catch(() => sendUnauthorized(res))
44
+ }
45
+
46
+ function sendUnauthorized(res: Response) {
47
+ // Return 401 with WWW-Authenticate header pointing to GitLab discovery
48
+ // The resource_id is the URL of THIS server (the MCP server)
49
+ const resourceId = process.env.MCP_SERVER_URL || `https://${process.env.RAILWAY_STATIC_URL || 'localhost'}`
50
+
51
+ // Per RFC 9728, we point to our own protected resource metadata endpoint
52
+ // which then points to the authorization server (GitLab)
53
+ res.setHeader('WWW-Authenticate', `OAuth p="ignored", resource_metadata="${resourceId}/.well-known/oauth-protected-resource"`)
54
+ res.status(401).send('Unauthorized')
55
+ }
56
+
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ export function oauthEndpoints(app: any) {
59
+ app.get('/.well-known/oauth-protected-resource', (req: Request, res: Response) => {
60
+ res.json({
61
+ authorization_servers: [
62
+ GITLAB_URL
63
+ ],
64
+ resource: process.env.MCP_SERVER_URL || `https://${req.get('host')}`,
65
+ scopes_supported: ["api", "read_user"]
66
+ })
67
+ })
68
+
69
+ // If GitLab doesn't strictly follow RFC 8414 at /.well-known/oauth-authorization-server,
70
+ // we might need to proxy or hardcode its endpoints here.
71
+ // But GitLab does support OIDC discovery at /.well-known/openid-configuration
72
+ // For MCP, the client should resolve the auth server metadata from the URL we provided above.
73
+ }
@@ -0,0 +1,58 @@
1
+ import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import {SSEServerTransport} from '@modelcontextprotocol/sdk/server/sse.js'
3
+ import express from 'express'
4
+
5
+ import {authMiddleware, oauthEndpoints} from './auth.js'
6
+ import {registerEmissionsTools} from './tools/emissions.js'
7
+ import {registerProfileTools} from './tools/profiles.js'
8
+
9
+ async function main() {
10
+ const server = new McpServer({
11
+ name: 'duoops-carbon',
12
+ version: '1.0.0',
13
+ })
14
+
15
+ // Register tools
16
+ registerEmissionsTools(server)
17
+ registerProfileTools(server)
18
+
19
+ const app = express()
20
+
21
+ app.get('/health', (_req, res) => {
22
+ res.json({service: 'duoops-mcp', status: 'ok', version: '1.0.0'})
23
+ })
24
+
25
+ oauthEndpoints(app)
26
+
27
+ // MCP SSE Transport setup
28
+ let transport: SSEServerTransport
29
+
30
+ app.get('/sse', authMiddleware, async (req, res) => {
31
+ transport = new SSEServerTransport('/message', res)
32
+ await server.connect(transport)
33
+ })
34
+
35
+ app.post('/message', authMiddleware, async (req, res) => {
36
+ if (!transport) {
37
+ res.sendStatus(400)
38
+ return
39
+ }
40
+
41
+ await transport.handlePostMessage(req, res)
42
+ })
43
+
44
+ // Legacy/Simple HTTP endpoint (optional, if SSE not supported by some clients)
45
+ // Currently we focus on SSE as per SDK standard for HTTP transport
46
+
47
+ const port = process.env.PORT || 8080
48
+ app.listen(port, () => {
49
+ console.log(`DuoOps MCP Server running on port ${port}`)
50
+ console.log(`OAuth discovery at /.well-known/oauth-protected-resource`)
51
+ })
52
+ }
53
+
54
+ // eslint-disable-next-line unicorn/prefer-top-level-await
55
+ main().catch((error) => {
56
+ console.error('Fatal error:', error)
57
+ process.exit(1) // eslint-disable-line n/no-process-exit, unicorn/no-process-exit
58
+ })
@@ -0,0 +1,122 @@
1
+ /* eslint-disable camelcase */
2
+ import {BigQuery} from '@google-cloud/bigquery'
3
+
4
+ import {EmissionsRecord} from './types.js'
5
+
6
+ export class BigQueryService {
7
+ private bigquery: BigQuery
8
+ private datasetId: string
9
+ private projectId: string
10
+ private tableId: string
11
+
12
+ constructor() {
13
+ this.projectId = process.env.GCP_PROJECT_ID || ''
14
+ this.datasetId = process.env.BQ_DATASET || ''
15
+ this.tableId = process.env.BQ_TABLE || ''
16
+
17
+ if (!this.projectId || !this.datasetId || !this.tableId) {
18
+ console.warn('BigQuery configuration missing: GCP_PROJECT_ID, BQ_DATASET, or BQ_TABLE env vars not set.')
19
+ }
20
+
21
+ this.bigquery = new BigQuery({projectId: this.projectId})
22
+ }
23
+
24
+ async getJobEmissions(jobId: number, projectId?: number): Promise<EmissionsRecord | null> {
25
+ const query = `
26
+ SELECT
27
+ job_id,
28
+ pipeline_id,
29
+ gitlab_project_id as project_id,
30
+ machine_type,
31
+ region,
32
+ total_emissions,
33
+ (cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
34
+ created_at,
35
+ runtime_seconds
36
+ FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
37
+ WHERE job_id = @jobId
38
+ ${projectId ? 'AND gitlab_project_id = @projectId' : ''}
39
+ LIMIT 1
40
+ `
41
+
42
+ const options = {
43
+ params: {jobId, projectId},
44
+ query,
45
+ }
46
+
47
+ const [rows] = await this.bigquery.query(options)
48
+ return rows.length > 0 ? (rows[0] as EmissionsRecord) : null
49
+ }
50
+
51
+ async getPipelineEmissions(pipelineId: number, projectId?: number): Promise<{
52
+ jobs: Partial<EmissionsRecord>[];
53
+ pipeline_id: number;
54
+ total_emissions_g: number;
55
+ total_energy_kwh: number;
56
+ }> {
57
+ const query = `
58
+ SELECT
59
+ job_id,
60
+ machine_type,
61
+ total_emissions,
62
+ (cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
63
+ runtime_seconds
64
+ FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
65
+ WHERE pipeline_id = @pipelineId
66
+ ${projectId ? 'AND gitlab_project_id = @projectId' : ''}
67
+ `
68
+
69
+ const options = {
70
+ params: {pipelineId, projectId},
71
+ query,
72
+ }
73
+
74
+ const [rows] = await this.bigquery.query(options)
75
+
76
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
77
+ const totalEmissions = rows.reduce((sum: number, row: any) => sum + (row.total_emissions || 0), 0)
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ const totalEnergy = rows.reduce((sum: number, row: any) => sum + (row.total_energy_kwh || 0), 0)
80
+
81
+ return {
82
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
83
+ jobs: rows.map((r: any) => ({
84
+ job_id: r.job_id,
85
+ machine_type: r.machine_type,
86
+ runtime_seconds: r.runtime_seconds,
87
+ total_emissions: r.total_emissions,
88
+ total_energy_kwh: r.total_energy_kwh
89
+ })),
90
+ pipeline_id: pipelineId,
91
+ total_emissions_g: totalEmissions,
92
+ total_energy_kwh: totalEnergy
93
+ }
94
+ }
95
+
96
+ async getProjectEmissionsSummary(projectId: number, limit: number = 10): Promise<EmissionsRecord[]> {
97
+ const query = `
98
+ SELECT
99
+ job_id,
100
+ pipeline_id,
101
+ gitlab_project_id as project_id,
102
+ machine_type,
103
+ region,
104
+ total_emissions,
105
+ (cpu_energy_kwh + ram_energy_kwh) as total_energy_kwh,
106
+ created_at,
107
+ runtime_seconds
108
+ FROM \`${this.projectId}.${this.datasetId}.${this.tableId}\`
109
+ WHERE gitlab_project_id = @projectId
110
+ ORDER BY created_at DESC
111
+ LIMIT @limit
112
+ `
113
+
114
+ const options = {
115
+ params: {limit, projectId},
116
+ query,
117
+ }
118
+
119
+ const [rows] = await this.bigquery.query(options)
120
+ return rows as EmissionsRecord[]
121
+ }
122
+ }
@@ -0,0 +1,33 @@
1
+ export class IntensityProvider {
2
+ async getCarbonIntensity(zone: string): Promise<number> {
3
+ // Electricity Maps API or similar would go here.
4
+ // For this implementation, we'll return a static average if we can't fetch real data,
5
+ // or simulate a fetch to public carbon intensity APIs if available without auth.
6
+ // This mirrors the CLI's simple implementation which currently uses static fallbacks or public APIs.
7
+
8
+ // Simplification for the hackathon: use a mapping of common zones to average intensities
9
+ const averages: Record<string, number> = {
10
+ 'AU': 300,
11
+ 'BR': 600,
12
+ 'DE': 350,
13
+ 'FR': 50,
14
+ 'GB': 60,
15
+ 'IE': 230,
16
+ 'IN': 650,
17
+ 'JP': 600,
18
+ 'NL': 350,
19
+ 'US-CAL-CISO': 370,
20
+ 'US-MIDA-PJM': 390,
21
+ 'US-NW-PACW': 150,
22
+ 'US-NY-NYIS': 300,
23
+ 'US-TEX-NYIS': 400,
24
+ }
25
+
26
+ // Try to find a match or partial match
27
+ for (const [key, value] of Object.entries(averages)) {
28
+ if (zone.includes(key)) return value
29
+ }
30
+
31
+ return 475 // Global average fallback
32
+ }
33
+ }
@@ -0,0 +1,102 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import {CloudProvider, MachineProfile, PowerPoint} from './types.js'
5
+
6
+ type RawMachineProfile = {
7
+ cpu_power_profile?: PowerPoint[];
8
+ matched_cpu_profile?: string;
9
+ memory_gb: number | string;
10
+ platform_cpu?: string;
11
+ ram_power_profile?: unknown;
12
+ scope3_emissions_hourly?: number;
13
+ vcpus: number | string;
14
+ }
15
+
16
+ type RawCpuProfile = {
17
+ power_profile: PowerPoint[];
18
+ tdp_watts: number;
19
+ }
20
+
21
+ type CpuPhysicalSpec = {
22
+ architecture: string;
23
+ cores: number;
24
+ note?: string;
25
+ source: string;
26
+ source_name: string;
27
+ tdp_watts: number;
28
+ threads: number;
29
+ }
30
+
31
+ type CpuPhysicalSpecs = Record<string, CpuPhysicalSpec> & {
32
+ _metadata: Record<string, string>;
33
+ }
34
+
35
+ export class PowerProfileRepository {
36
+ private awsMachines: null | Record<string, RawMachineProfile> = null
37
+ private cpuPhysicalSpecs: CpuPhysicalSpecs | null = null
38
+ private cpuProfiles: null | Record<string, RawCpuProfile> = null
39
+ private gcpMachines: null | Record<string, RawMachineProfile> = null
40
+
41
+ // Default to ./data relative to CWD, which works in the container
42
+ constructor(private readonly dataDir: string = path.resolve(process.cwd(), 'data')) {}
43
+
44
+ async getMachineProfile(provider: CloudProvider, machineType: string): Promise<MachineProfile | null> {
45
+ const machines = this.loadMachineData(provider)
46
+ const raw = machines[machineType]
47
+ if (!raw) return null
48
+
49
+ const powerProfile = raw.cpu_power_profile ? this.normalizePowerPoints(raw.cpu_power_profile) : []
50
+
51
+ return {
52
+ cpuPowerProfile: powerProfile,
53
+ machineType,
54
+ matchedCpuProfile: raw.matched_cpu_profile,
55
+ memoryGb: this.normalizeNumber(raw.memory_gb),
56
+ platformCpu: raw.platform_cpu,
57
+ scope3EmissionsHourly: raw.scope3_emissions_hourly,
58
+ vcpus: this.normalizeNumber(raw.vcpus),
59
+ }
60
+ }
61
+
62
+ private loadJsonFile<T>(filename: string): T {
63
+ const filePath = path.join(this.dataDir, filename)
64
+ try {
65
+ const raw = fs.readFileSync(filePath, 'utf8')
66
+ return JSON.parse(raw) as T
67
+ } catch (error) {
68
+ console.error(`Failed to load data file ${filename}:`, error)
69
+ return {} as T
70
+ }
71
+ }
72
+
73
+ private loadMachineData(provider: CloudProvider): Record<string, RawMachineProfile> {
74
+ if (provider === 'gcp') {
75
+ if (!this.gcpMachines) {
76
+ this.gcpMachines = this.loadJsonFile<Record<string, RawMachineProfile>>('gcp_machine_power_profiles.json')
77
+ }
78
+
79
+ return this.gcpMachines
80
+ }
81
+
82
+ if (!this.awsMachines) {
83
+ this.awsMachines = this.loadJsonFile<Record<string, RawMachineProfile>>('aws_machine_power_profiles.json')
84
+ }
85
+
86
+ return this.awsMachines
87
+ }
88
+
89
+ private normalizeNumber(value: number | string | undefined): number {
90
+ if (typeof value === 'number') return value
91
+ if (!value) return 0
92
+ const parsed = Number.parseFloat(value)
93
+ return Number.isFinite(parsed) ? parsed : 0
94
+ }
95
+
96
+ private normalizePowerPoints(points: PowerPoint[]): PowerPoint[] {
97
+ return points.map((p) => ({
98
+ percentage: Number(p.percentage),
99
+ watts: Number(p.watts),
100
+ })).sort((a, b) => a.percentage - b.percentage)
101
+ }
102
+ }
@@ -0,0 +1,66 @@
1
+ // Copied and adapted from src/lib/measure/types.ts
2
+
3
+ export type CloudProvider = 'aws' | 'gcp';
4
+
5
+ export interface TimeseriesPoint {
6
+ timestamp: string;
7
+ value: number;
8
+ }
9
+
10
+ export interface JobInput {
11
+ cpuTimeseries: TimeseriesPoint[];
12
+ machineType: string;
13
+ provider: CloudProvider;
14
+ ramSizeTimeseries?: TimeseriesPoint[];
15
+ ramUsedTimeseries: TimeseriesPoint[];
16
+ region: string;
17
+ }
18
+
19
+ export interface PowerPoint {
20
+ percentage: number;
21
+ watts: number;
22
+ }
23
+
24
+ export interface MachineProfile {
25
+ cpuPowerProfile?: PowerPoint[];
26
+ machineType: string;
27
+ matchedCpuProfile?: string;
28
+ memoryGb: number;
29
+ platformCpu?: string;
30
+ scope3EmissionsHourly?: number;
31
+ vcpus: number;
32
+ }
33
+
34
+ export interface ZoneMapping {
35
+ carbonIntensityUrl: string;
36
+ pue: number;
37
+ zone: string;
38
+ }
39
+
40
+ export interface CarbonResult {
41
+ carbonIntensity: number;
42
+ cpuEmissions: number;
43
+ cpuEnergyKwh: number;
44
+ machineType: string;
45
+ provider: CloudProvider;
46
+ pue: number;
47
+ ramEmissions: number;
48
+ ramEnergyKwh: number;
49
+ region: string;
50
+ runtimeHours: number;
51
+ scope3Emissions: number;
52
+ totalEmissions: number;
53
+ zone: string;
54
+ }
55
+
56
+ export interface EmissionsRecord {
57
+ created_at: string;
58
+ job_id: number;
59
+ machine_type: string;
60
+ pipeline_id: number;
61
+ project_id: number;
62
+ region: string;
63
+ runtime_seconds: number;
64
+ total_emissions: number;
65
+ total_energy_kwh: number;
66
+ }