hyperping 0.1.0__py3-none-any.whl
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.
- hyperping/__init__.py +151 -0
- hyperping/_incidents_mixin.py +198 -0
- hyperping/_maintenance_mixin.py +195 -0
- hyperping/_monitors_mixin.py +250 -0
- hyperping/_outages_mixin.py +102 -0
- hyperping/_statuspages_mixin.py +226 -0
- hyperping/_version.py +1 -0
- hyperping/client.py +452 -0
- hyperping/endpoints.py +231 -0
- hyperping/exceptions.py +89 -0
- hyperping/models.py +769 -0
- hyperping/py.typed +0 -0
- hyperping-0.1.0.dist-info/METADATA +223 -0
- hyperping-0.1.0.dist-info/RECORD +16 -0
- hyperping-0.1.0.dist-info/WHEEL +4 -0
- hyperping-0.1.0.dist-info/licenses/LICENSE +21 -0
hyperping/models.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
"""Pydantic models for Hyperping API requests and responses."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from enum import IntEnum, StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HttpMethod(StrEnum):
|
|
11
|
+
"""HTTP methods supported by Hyperping monitors."""
|
|
12
|
+
|
|
13
|
+
GET = "GET"
|
|
14
|
+
POST = "POST"
|
|
15
|
+
PUT = "PUT"
|
|
16
|
+
PATCH = "PATCH"
|
|
17
|
+
DELETE = "DELETE"
|
|
18
|
+
HEAD = "HEAD"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MonitorFrequency(IntEnum):
|
|
22
|
+
"""Monitor check frequencies in seconds."""
|
|
23
|
+
|
|
24
|
+
SECONDS_10 = 10
|
|
25
|
+
SECONDS_20 = 20
|
|
26
|
+
SECONDS_30 = 30
|
|
27
|
+
MINUTES_1 = 60
|
|
28
|
+
MINUTES_2 = 120
|
|
29
|
+
MINUTES_3 = 180
|
|
30
|
+
MINUTES_5 = 300
|
|
31
|
+
MINUTES_10 = 600
|
|
32
|
+
MINUTES_30 = 1800
|
|
33
|
+
HOURS_1 = 3600
|
|
34
|
+
HOURS_6 = 21600
|
|
35
|
+
HOURS_12 = 43200
|
|
36
|
+
HOURS_24 = 86400
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MonitorTimeout(IntEnum):
|
|
40
|
+
"""Monitor request timeout options in seconds."""
|
|
41
|
+
|
|
42
|
+
SECONDS_5 = 5
|
|
43
|
+
SECONDS_10 = 10
|
|
44
|
+
SECONDS_15 = 15
|
|
45
|
+
SECONDS_20 = 20
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Region(StrEnum):
|
|
49
|
+
"""Hyperping monitoring regions.
|
|
50
|
+
|
|
51
|
+
Combined from official Hyperping API documentation and real API responses.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
# Europe
|
|
55
|
+
PARIS = "paris"
|
|
56
|
+
FRANKFURT = "frankfurt"
|
|
57
|
+
AMSTERDAM = "amsterdam"
|
|
58
|
+
LONDON = "london"
|
|
59
|
+
|
|
60
|
+
# Asia Pacific
|
|
61
|
+
SINGAPORE = "singapore"
|
|
62
|
+
SYDNEY = "sydney"
|
|
63
|
+
TOKYO = "tokyo"
|
|
64
|
+
SEOUL = "seoul"
|
|
65
|
+
MUMBAI = "mumbai"
|
|
66
|
+
BANGALORE = "bangalore"
|
|
67
|
+
|
|
68
|
+
# Americas
|
|
69
|
+
VIRGINIA = "virginia"
|
|
70
|
+
CALIFORNIA = "california"
|
|
71
|
+
SAN_FRANCISCO = "sanfrancisco"
|
|
72
|
+
OREGON = "oregon"
|
|
73
|
+
NYC = "nyc"
|
|
74
|
+
TORONTO = "toronto"
|
|
75
|
+
SAO_PAULO = "saopaulo"
|
|
76
|
+
|
|
77
|
+
# Middle East / Africa
|
|
78
|
+
BAHRAIN = "bahrain"
|
|
79
|
+
CAPE_TOWN = "capetown"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Default regions (common subset for balanced global coverage)
|
|
83
|
+
DEFAULT_REGIONS = [
|
|
84
|
+
Region.PARIS,
|
|
85
|
+
Region.FRANKFURT,
|
|
86
|
+
Region.AMSTERDAM,
|
|
87
|
+
Region.LONDON,
|
|
88
|
+
Region.SINGAPORE,
|
|
89
|
+
Region.SYDNEY,
|
|
90
|
+
Region.TOKYO,
|
|
91
|
+
Region.VIRGINIA,
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MonitorProtocol(StrEnum):
|
|
96
|
+
"""Monitor protocol types."""
|
|
97
|
+
|
|
98
|
+
HTTP = "http"
|
|
99
|
+
PORT = "port"
|
|
100
|
+
ICMP = "icmp"
|
|
101
|
+
DNS = "dns"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DnsRecordType(StrEnum):
|
|
105
|
+
"""DNS record types supported by Hyperping DNS monitors."""
|
|
106
|
+
|
|
107
|
+
A = "A"
|
|
108
|
+
AAAA = "AAAA"
|
|
109
|
+
CNAME = "CNAME"
|
|
110
|
+
MX = "MX"
|
|
111
|
+
NS = "NS"
|
|
112
|
+
TXT = "TXT"
|
|
113
|
+
SOA = "SOA"
|
|
114
|
+
SRV = "SRV"
|
|
115
|
+
CAA = "CAA"
|
|
116
|
+
PTR = "PTR"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RequestHeader(BaseModel):
|
|
120
|
+
"""HTTP header for monitor requests.
|
|
121
|
+
|
|
122
|
+
API format: [{"name": "Header-Name", "value": "header-value"}]
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
name: str = Field(..., description="Header name")
|
|
126
|
+
value: str = Field(..., description="Header value")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class LocalizedText(BaseModel):
|
|
130
|
+
"""Localized text supporting multiple languages.
|
|
131
|
+
|
|
132
|
+
Used for incident/maintenance titles and descriptions.
|
|
133
|
+
API format: {"en": "English text", "fr": "French text"}
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
model_config = ConfigDict(extra="allow", frozen=True)
|
|
137
|
+
|
|
138
|
+
en: str = Field(..., description="English text (required)")
|
|
139
|
+
fr: str | None = Field(default=None, description="French text")
|
|
140
|
+
de: str | None = Field(default=None, description="German text")
|
|
141
|
+
es: str | None = Field(default=None, description="Spanish text")
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def from_string(cls, text: str) -> "LocalizedText":
|
|
145
|
+
"""Create LocalizedText from a simple string (English only)."""
|
|
146
|
+
return cls(en=text)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class MonitorBase(BaseModel):
|
|
150
|
+
"""Base model for monitor data.
|
|
151
|
+
|
|
152
|
+
Field names match the official Hyperping API (snake_case).
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
|
|
156
|
+
|
|
157
|
+
name: str = Field(..., min_length=1, max_length=255, description="Monitor display name")
|
|
158
|
+
url: str = Field(..., description="URL to monitor")
|
|
159
|
+
protocol: MonitorProtocol = Field(
|
|
160
|
+
default=MonitorProtocol.HTTP,
|
|
161
|
+
description="Monitor protocol: http, port, or icmp",
|
|
162
|
+
)
|
|
163
|
+
http_method: HttpMethod = Field(
|
|
164
|
+
default=HttpMethod.GET,
|
|
165
|
+
alias="http_method",
|
|
166
|
+
description="HTTP method",
|
|
167
|
+
)
|
|
168
|
+
check_frequency: int = Field(
|
|
169
|
+
default=30,
|
|
170
|
+
alias="check_frequency",
|
|
171
|
+
description="Check frequency in seconds",
|
|
172
|
+
)
|
|
173
|
+
regions: list[str] = Field(
|
|
174
|
+
default_factory=lambda: [r.value for r in DEFAULT_REGIONS],
|
|
175
|
+
description="Monitoring regions",
|
|
176
|
+
)
|
|
177
|
+
request_headers: list[RequestHeader] = Field(
|
|
178
|
+
default_factory=list,
|
|
179
|
+
alias="request_headers",
|
|
180
|
+
description="Custom HTTP headers [{name, value}]",
|
|
181
|
+
)
|
|
182
|
+
request_body: str | None = Field(
|
|
183
|
+
default=None,
|
|
184
|
+
alias="request_body",
|
|
185
|
+
description="Request body for POST/PUT/PATCH",
|
|
186
|
+
)
|
|
187
|
+
follow_redirects: bool = Field(
|
|
188
|
+
default=True,
|
|
189
|
+
alias="follow_redirects",
|
|
190
|
+
description="Follow HTTP redirects",
|
|
191
|
+
)
|
|
192
|
+
expected_status_code: str = Field(
|
|
193
|
+
default="2xx",
|
|
194
|
+
alias="expected_status_code",
|
|
195
|
+
description="Expected status code (e.g., '200' or '2xx')",
|
|
196
|
+
)
|
|
197
|
+
required_keyword: str | None = Field(
|
|
198
|
+
default=None,
|
|
199
|
+
alias="required_keyword",
|
|
200
|
+
description="Required keyword in response body",
|
|
201
|
+
)
|
|
202
|
+
paused: bool = Field(default=False, description="Whether monitor is paused")
|
|
203
|
+
port: int | None = Field(default=None, description="Port number (for port protocol)")
|
|
204
|
+
alerts_wait: int | None = Field(
|
|
205
|
+
default=None,
|
|
206
|
+
alias="alerts_wait",
|
|
207
|
+
description="Seconds to wait before alerting",
|
|
208
|
+
)
|
|
209
|
+
escalation_policy: str | None = Field(
|
|
210
|
+
default=None,
|
|
211
|
+
alias="escalation_policy",
|
|
212
|
+
description="Escalation policy UUID",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# DNS-specific fields (only used when protocol="dns")
|
|
216
|
+
dns_record_type: str | None = Field(
|
|
217
|
+
default=None,
|
|
218
|
+
alias="dns_record_type",
|
|
219
|
+
description="DNS record type (A, AAAA, CNAME, MX, etc.)",
|
|
220
|
+
)
|
|
221
|
+
dns_nameserver: str | None = Field(
|
|
222
|
+
default=None,
|
|
223
|
+
alias="dns_nameserver",
|
|
224
|
+
description="Custom nameserver to query (e.g. 8.8.8.8)",
|
|
225
|
+
)
|
|
226
|
+
dns_expected_answer: str | None = Field(
|
|
227
|
+
default=None,
|
|
228
|
+
alias="dns_expected_answer",
|
|
229
|
+
description="Expected DNS answer to match against",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@field_validator("escalation_policy", mode="before")
|
|
233
|
+
@classmethod
|
|
234
|
+
def coerce_escalation_policy(cls, v: object) -> str | None:
|
|
235
|
+
"""Accept both plain UUID strings and {uuid, name} dicts from the API."""
|
|
236
|
+
if isinstance(v, dict):
|
|
237
|
+
return v.get("uuid")
|
|
238
|
+
return v # type: ignore[return-value]
|
|
239
|
+
|
|
240
|
+
# Helper methods for backward compatibility
|
|
241
|
+
def get_headers_dict(self) -> dict[str, str]:
|
|
242
|
+
"""Get headers as a dictionary for convenience."""
|
|
243
|
+
return {h.name: h.value for h in self.request_headers}
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def _remap_legacy_fields(data: dict[str, Any]) -> dict[str, Any]:
|
|
247
|
+
"""Remap legacy field names to current API field names."""
|
|
248
|
+
if "frequency" in data and "check_frequency" not in data:
|
|
249
|
+
data["check_frequency"] = data.pop("frequency")
|
|
250
|
+
if "method" in data and "http_method" not in data:
|
|
251
|
+
data["http_method"] = data.pop("method")
|
|
252
|
+
if "body" in data and "request_body" not in data:
|
|
253
|
+
data["request_body"] = data.pop("body")
|
|
254
|
+
if "headers" in data and "request_headers" not in data:
|
|
255
|
+
headers = data.pop("headers")
|
|
256
|
+
if isinstance(headers, dict):
|
|
257
|
+
data["request_headers"] = [{"name": k, "value": v} for k, v in headers.items()]
|
|
258
|
+
else:
|
|
259
|
+
data["request_headers"] = headers
|
|
260
|
+
if "expected_status" in data and "expected_status_code" not in data:
|
|
261
|
+
data["expected_status_code"] = str(data.pop("expected_status"))
|
|
262
|
+
return data
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class MonitorCreate(MonitorBase):
|
|
266
|
+
"""Model for creating a new monitor.
|
|
267
|
+
|
|
268
|
+
All fields from MonitorBase are available. Required: name, url, protocol.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
def __init__(self, **data: Any) -> None:
|
|
272
|
+
MonitorBase._remap_legacy_fields(data)
|
|
273
|
+
super().__init__(**data)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class MonitorUpdate(BaseModel):
|
|
277
|
+
"""Model for updating an existing monitor (all fields optional)."""
|
|
278
|
+
|
|
279
|
+
model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
|
|
280
|
+
|
|
281
|
+
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
282
|
+
url: str | None = None
|
|
283
|
+
protocol: MonitorProtocol | None = None
|
|
284
|
+
http_method: HttpMethod | None = Field(default=None, alias="http_method")
|
|
285
|
+
check_frequency: int | None = Field(default=None, alias="check_frequency")
|
|
286
|
+
regions: list[str] | None = None
|
|
287
|
+
request_headers: list[RequestHeader] | None = Field(default=None, alias="request_headers")
|
|
288
|
+
request_body: str | None = Field(default=None, alias="request_body")
|
|
289
|
+
follow_redirects: bool | None = Field(default=None, alias="follow_redirects")
|
|
290
|
+
expected_status_code: str | None = Field(default=None, alias="expected_status_code")
|
|
291
|
+
required_keyword: str | None = Field(default=None, alias="required_keyword")
|
|
292
|
+
paused: bool | None = None
|
|
293
|
+
port: int | None = None
|
|
294
|
+
alerts_wait: int | None = Field(default=None, alias="alerts_wait")
|
|
295
|
+
escalation_policy: str | None = Field(default=None, alias="escalation_policy")
|
|
296
|
+
dns_record_type: str | None = Field(default=None, alias="dns_record_type")
|
|
297
|
+
dns_nameserver: str | None = Field(default=None, alias="dns_nameserver")
|
|
298
|
+
dns_expected_answer: str | None = Field(default=None, alias="dns_expected_answer")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class Monitor(MonitorBase):
|
|
302
|
+
"""Model for a monitor response from Hyperping API."""
|
|
303
|
+
|
|
304
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
|
|
305
|
+
|
|
306
|
+
uuid: str = Field(..., description="Monitor unique identifier (mon_xxx)")
|
|
307
|
+
project_uuid: str | None = Field(default=None, alias="projectUuid")
|
|
308
|
+
|
|
309
|
+
# Override paused from MonitorBase (it's in response, not just create)
|
|
310
|
+
paused: bool = Field(default=False, description="Whether monitor is paused")
|
|
311
|
+
down: bool = Field(default=False, description="Whether monitor is currently down")
|
|
312
|
+
|
|
313
|
+
def __init__(self, **data: Any) -> None:
|
|
314
|
+
# Handle both uuid and monitorUuid (legacy alias)
|
|
315
|
+
if "monitorUuid" in data and "uuid" not in data:
|
|
316
|
+
data["uuid"] = data.pop("monitorUuid")
|
|
317
|
+
|
|
318
|
+
# Apply shared legacy field remapping
|
|
319
|
+
MonitorBase._remap_legacy_fields(data)
|
|
320
|
+
|
|
321
|
+
# Handle API returning headers as dict sometimes
|
|
322
|
+
if "request_headers" in data and isinstance(data["request_headers"], dict):
|
|
323
|
+
data["request_headers"] = [
|
|
324
|
+
{"name": k, "value": v} for k, v in data["request_headers"].items()
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
# Handle API returning null for optional fields
|
|
328
|
+
if "request_headers" in data and data["request_headers"] is None:
|
|
329
|
+
data["request_headers"] = []
|
|
330
|
+
if "http_method" in data and data["http_method"] is None:
|
|
331
|
+
del data["http_method"] # Use MonitorBase default (GET)
|
|
332
|
+
if "expected_status_code" in data:
|
|
333
|
+
if data["expected_status_code"] is None:
|
|
334
|
+
del data["expected_status_code"] # Use MonitorBase default ("2xx")
|
|
335
|
+
elif isinstance(data["expected_status_code"], int):
|
|
336
|
+
data["expected_status_code"] = str(data["expected_status_code"])
|
|
337
|
+
|
|
338
|
+
super().__init__(**data)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class ReportPeriod(BaseModel):
|
|
342
|
+
"""Time period for a monitor report."""
|
|
343
|
+
|
|
344
|
+
from_date: str = Field(..., alias="from", description="Start date ISO 8601")
|
|
345
|
+
to_date: str = Field(..., alias="to", description="End date ISO 8601")
|
|
346
|
+
|
|
347
|
+
model_config = ConfigDict(populate_by_name=True, frozen=True)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class OutageDetail(BaseModel):
|
|
351
|
+
"""Details about a specific outage."""
|
|
352
|
+
|
|
353
|
+
start_date: str = Field(..., alias="startDate")
|
|
354
|
+
end_date: str = Field(..., alias="endDate")
|
|
355
|
+
|
|
356
|
+
model_config = ConfigDict(populate_by_name=True, frozen=True, extra="ignore")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class OutageStats(BaseModel):
|
|
360
|
+
"""Statistics about outages in a report period."""
|
|
361
|
+
|
|
362
|
+
count: int = Field(default=0, description="Total number of outages")
|
|
363
|
+
total_downtime: int = Field(
|
|
364
|
+
default=0, alias="totalDowntime", description="Total downtime in seconds"
|
|
365
|
+
)
|
|
366
|
+
total_downtime_formatted: str = Field(
|
|
367
|
+
default="", alias="totalDowntimeFormatted", description="Human-readable downtime"
|
|
368
|
+
)
|
|
369
|
+
longest_outage: int = Field(
|
|
370
|
+
default=0, alias="longestOutage", description="Longest outage in seconds"
|
|
371
|
+
)
|
|
372
|
+
longest_outage_formatted: str = Field(
|
|
373
|
+
default="", alias="longestOutageFormatted", description="Human-readable longest outage"
|
|
374
|
+
)
|
|
375
|
+
details: list[OutageDetail] = Field(default_factory=list, description="Outage details")
|
|
376
|
+
|
|
377
|
+
model_config = ConfigDict(populate_by_name=True, frozen=True)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class MonitorReport(BaseModel):
|
|
381
|
+
"""Model for monitor uptime report from v2 API.
|
|
382
|
+
|
|
383
|
+
API: GET /v2/reporting/monitor-reports?period=30d
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
|
|
387
|
+
|
|
388
|
+
uuid: str = Field(..., description="Monitor UUID")
|
|
389
|
+
name: str = Field(..., description="Monitor name")
|
|
390
|
+
protocol: str = Field(..., description="Monitor protocol")
|
|
391
|
+
period: ReportPeriod = Field(..., description="Report time period")
|
|
392
|
+
sla: float = Field(..., description="SLA percentage (e.g., 99.184)")
|
|
393
|
+
outages: OutageStats = Field(..., description="Outage statistics")
|
|
394
|
+
mttr: int = Field(default=0, description="Mean time to recovery in seconds")
|
|
395
|
+
mttr_formatted: str = Field(
|
|
396
|
+
default="0s", alias="mttrFormatted", description="MTTR human-readable"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class MonitorListResponse(BaseModel):
|
|
401
|
+
"""Response model for list monitors endpoint."""
|
|
402
|
+
|
|
403
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
404
|
+
|
|
405
|
+
monitors: list[Monitor] = Field(default_factory=list)
|
|
406
|
+
total: int = Field(default=0)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class APIErrorResponse(BaseModel):
|
|
410
|
+
"""Model for API error responses."""
|
|
411
|
+
|
|
412
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
413
|
+
|
|
414
|
+
error: str = Field(default="Unknown error")
|
|
415
|
+
message: str | None = None
|
|
416
|
+
details: list[dict[str, Any]] | None = None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ==================== Incident Models ====================
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class IncidentType(StrEnum):
|
|
423
|
+
"""Incident type values from v3 API."""
|
|
424
|
+
|
|
425
|
+
OUTAGE = "outage"
|
|
426
|
+
INCIDENT = "incident"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class IncidentUpdateType(StrEnum):
|
|
430
|
+
"""Incident update type values from v3 API."""
|
|
431
|
+
|
|
432
|
+
INVESTIGATING = "investigating"
|
|
433
|
+
IDENTIFIED = "identified"
|
|
434
|
+
UPDATE = "update"
|
|
435
|
+
MONITORING = "monitoring"
|
|
436
|
+
RESOLVED = "resolved"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class IncidentUpdate(BaseModel):
|
|
440
|
+
"""Model for an incident update from v3 API."""
|
|
441
|
+
|
|
442
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
|
|
443
|
+
|
|
444
|
+
uuid: str = Field(..., description="Update UUID")
|
|
445
|
+
date: str = Field(..., description="Update timestamp ISO 8601")
|
|
446
|
+
text: LocalizedText = Field(..., description="Localized update text")
|
|
447
|
+
type: str = Field(..., description="Update type")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class AddIncidentUpdateRequest(BaseModel):
|
|
451
|
+
"""Model for adding an update to an incident.
|
|
452
|
+
|
|
453
|
+
API: POST /v3/incidents/{uuid}/updates
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
|
|
457
|
+
|
|
458
|
+
text: LocalizedText = Field(..., description="Localized update text")
|
|
459
|
+
type: IncidentUpdateType = Field(..., description="Update type")
|
|
460
|
+
date: str = Field(..., description="Update date ISO 8601")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class IncidentCreate(BaseModel):
|
|
464
|
+
"""Model for creating a new incident via v3 API.
|
|
465
|
+
|
|
466
|
+
API: POST /v3/incidents
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
|
|
470
|
+
|
|
471
|
+
title: LocalizedText = Field(..., description="Localized incident title")
|
|
472
|
+
text: LocalizedText = Field(..., description="Localized incident message")
|
|
473
|
+
type: IncidentType = Field(
|
|
474
|
+
default=IncidentType.INCIDENT,
|
|
475
|
+
description="Incident type: outage or incident",
|
|
476
|
+
)
|
|
477
|
+
affected_components: list[str] = Field(
|
|
478
|
+
default_factory=list,
|
|
479
|
+
alias="affectedComponents",
|
|
480
|
+
description="Affected component UUIDs",
|
|
481
|
+
)
|
|
482
|
+
statuspages: list[str] = Field(
|
|
483
|
+
...,
|
|
484
|
+
description="Status page UUIDs to display incident on (required)",
|
|
485
|
+
)
|
|
486
|
+
date: str | None = Field(
|
|
487
|
+
default=None,
|
|
488
|
+
description="Incident date ISO 8601 (optional)",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class IncidentUpdateRequest(BaseModel):
|
|
493
|
+
"""Model for updating an existing incident via v3 API.
|
|
494
|
+
|
|
495
|
+
API: PUT /v3/incidents/{uuid}
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
|
|
499
|
+
|
|
500
|
+
title: LocalizedText | None = None
|
|
501
|
+
type: IncidentType | None = None
|
|
502
|
+
affected_components: list[str] | None = Field(default=None, alias="affectedComponents")
|
|
503
|
+
statuspages: list[str] | None = None
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class Incident(BaseModel):
|
|
507
|
+
"""Model for an incident response from v3 API.
|
|
508
|
+
|
|
509
|
+
API: GET /v3/incidents, GET /v3/incidents/{uuid}
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
|
|
513
|
+
|
|
514
|
+
uuid: str = Field(..., description="Incident UUID (inci_xxx)")
|
|
515
|
+
date: str | None = Field(default=None, description="Incident date ISO 8601")
|
|
516
|
+
title: LocalizedText = Field(..., description="Localized incident title")
|
|
517
|
+
text: LocalizedText | None = Field(default=None, description="Localized incident message")
|
|
518
|
+
type: str = Field(..., description="Incident type: outage or incident")
|
|
519
|
+
affected_components: list[str] = Field(
|
|
520
|
+
default_factory=list,
|
|
521
|
+
alias="affectedComponents",
|
|
522
|
+
description="Affected component UUIDs",
|
|
523
|
+
)
|
|
524
|
+
statuspages: list[str] = Field(
|
|
525
|
+
default_factory=list,
|
|
526
|
+
description="Status page UUIDs",
|
|
527
|
+
)
|
|
528
|
+
updates: list[IncidentUpdate] = Field(default_factory=list)
|
|
529
|
+
|
|
530
|
+
@property
|
|
531
|
+
def is_resolved(self) -> bool:
|
|
532
|
+
"""Check if incident has a resolved update."""
|
|
533
|
+
return any(u.type == IncidentUpdateType.RESOLVED.value for u in self.updates)
|
|
534
|
+
|
|
535
|
+
@property
|
|
536
|
+
def title_en(self) -> str:
|
|
537
|
+
"""Get English title for convenience."""
|
|
538
|
+
return self.title.en
|
|
539
|
+
|
|
540
|
+
@property
|
|
541
|
+
def text_en(self) -> str:
|
|
542
|
+
"""Get English text for convenience."""
|
|
543
|
+
return self.text.en if self.text else ""
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# Legacy aliases for backward compatibility (used by _incidents_mixin.py)
|
|
547
|
+
IncidentStatus = IncidentUpdateType # Old name -> new name
|
|
548
|
+
IncidentUpdateCreate = AddIncidentUpdateRequest # Old name -> new name
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ==================== Maintenance Models ====================
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class NotificationOption(StrEnum):
|
|
555
|
+
"""Maintenance notification options."""
|
|
556
|
+
|
|
557
|
+
SCHEDULED = "scheduled"
|
|
558
|
+
IMMEDIATE = "immediate"
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
class MaintenanceCreate(BaseModel):
|
|
562
|
+
"""Model for creating a maintenance window via v1 API.
|
|
563
|
+
|
|
564
|
+
API: POST /v1/maintenance-windows
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
|
|
568
|
+
|
|
569
|
+
name: str = Field(..., min_length=1, max_length=255, description="Internal name")
|
|
570
|
+
start_date: str = Field(
|
|
571
|
+
...,
|
|
572
|
+
alias="start_date",
|
|
573
|
+
description="Start date ISO 8601 (e.g., 2025-05-18T14:30:00Z)",
|
|
574
|
+
)
|
|
575
|
+
end_date: str = Field(
|
|
576
|
+
...,
|
|
577
|
+
alias="end_date",
|
|
578
|
+
description="End date ISO 8601 (e.g., 2025-05-18T15:30:00Z)",
|
|
579
|
+
)
|
|
580
|
+
monitors: list[str] = Field(
|
|
581
|
+
...,
|
|
582
|
+
description="Array of monitor UUIDs affected by maintenance",
|
|
583
|
+
)
|
|
584
|
+
statuspages: list[str] = Field(
|
|
585
|
+
default_factory=list,
|
|
586
|
+
description="Array of status page UUIDs to display maintenance on",
|
|
587
|
+
)
|
|
588
|
+
title: LocalizedText | None = Field(
|
|
589
|
+
default=None,
|
|
590
|
+
description="Localized public title",
|
|
591
|
+
)
|
|
592
|
+
text: LocalizedText | None = Field(
|
|
593
|
+
default=None,
|
|
594
|
+
description="Localized public description",
|
|
595
|
+
)
|
|
596
|
+
notification_option: NotificationOption | None = Field(
|
|
597
|
+
default=None,
|
|
598
|
+
alias="notificationOption",
|
|
599
|
+
description="When to notify: scheduled or immediate",
|
|
600
|
+
)
|
|
601
|
+
notification_minutes: int | None = Field(
|
|
602
|
+
default=None,
|
|
603
|
+
alias="notificationMinutes",
|
|
604
|
+
description="Minutes before start to send notification",
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
class MaintenanceUpdate(BaseModel):
|
|
609
|
+
"""Model for updating a maintenance window via v1 API.
|
|
610
|
+
|
|
611
|
+
API: PUT /v1/maintenance-windows/{uuid}
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
|
|
615
|
+
|
|
616
|
+
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
617
|
+
start_date: str | None = Field(default=None, alias="start_date")
|
|
618
|
+
end_date: str | None = Field(default=None, alias="end_date")
|
|
619
|
+
monitors: list[str] | None = None
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
class Maintenance(BaseModel):
|
|
623
|
+
"""Model for a maintenance window response from v1 API.
|
|
624
|
+
|
|
625
|
+
API: GET /v1/maintenance-windows, GET /v1/maintenance-windows/{uuid}
|
|
626
|
+
"""
|
|
627
|
+
|
|
628
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
|
|
629
|
+
|
|
630
|
+
uuid: str = Field(..., description="Maintenance UUID (mw_xxx)")
|
|
631
|
+
name: str = Field(..., description="Internal name")
|
|
632
|
+
title: LocalizedText | None = Field(default=None, description="Localized public title")
|
|
633
|
+
text: LocalizedText | None = Field(default=None, description="Localized public description")
|
|
634
|
+
|
|
635
|
+
@field_validator("title", "text", mode="before")
|
|
636
|
+
@classmethod
|
|
637
|
+
def coerce_empty_localized_text(cls, v: object) -> object:
|
|
638
|
+
"""Convert empty dicts {} to None — the API returns {} for unset titles."""
|
|
639
|
+
if isinstance(v, dict) and not v:
|
|
640
|
+
return None
|
|
641
|
+
return v
|
|
642
|
+
|
|
643
|
+
start_date: str | None = Field(default=None, alias="start_date")
|
|
644
|
+
end_date: str | None = Field(default=None, alias="end_date")
|
|
645
|
+
timezone: str = Field(default="UTC", description="Timezone")
|
|
646
|
+
monitors: list[str] = Field(default_factory=list, description="Affected monitor UUIDs")
|
|
647
|
+
statuspages: list[str] = Field(default_factory=list, description="Status page UUIDs")
|
|
648
|
+
bulk_uuid: str | None = Field(default=None, alias="bulkUuid")
|
|
649
|
+
created_by: str | None = Field(default=None, alias="createdBy")
|
|
650
|
+
created_at: str | None = Field(default=None, alias="createdAt")
|
|
651
|
+
notification_option: str | None = Field(default=None, alias="notificationOption")
|
|
652
|
+
notification_minutes: int | None = Field(default=None, alias="notificationMinutes")
|
|
653
|
+
|
|
654
|
+
def is_active(self, at_time: datetime | None = None) -> bool:
|
|
655
|
+
"""Check if maintenance is currently active.
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
at_time: Time to check (defaults to now)
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
True if maintenance window is currently active
|
|
662
|
+
"""
|
|
663
|
+
if at_time is None:
|
|
664
|
+
at_time = datetime.now(UTC)
|
|
665
|
+
|
|
666
|
+
if self.start_date and self.end_date:
|
|
667
|
+
from datetime import datetime as dt
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
start = dt.fromisoformat(self.start_date.replace("Z", "+00:00"))
|
|
671
|
+
end = dt.fromisoformat(self.end_date.replace("Z", "+00:00"))
|
|
672
|
+
# Make at_time timezone-aware if needed
|
|
673
|
+
if at_time.tzinfo is None:
|
|
674
|
+
at_time = at_time.replace(tzinfo=UTC)
|
|
675
|
+
return start <= at_time <= end
|
|
676
|
+
except (ValueError, AttributeError):
|
|
677
|
+
return False
|
|
678
|
+
|
|
679
|
+
return False
|
|
680
|
+
|
|
681
|
+
def affects_monitor(self, monitor_uuid: str) -> bool:
|
|
682
|
+
"""Check if this maintenance affects a specific monitor.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
monitor_uuid: Monitor UUID to check
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
True if monitor is affected by this maintenance
|
|
689
|
+
"""
|
|
690
|
+
return monitor_uuid in self.monitors
|
|
691
|
+
|
|
692
|
+
@property
|
|
693
|
+
def title_en(self) -> str | None:
|
|
694
|
+
"""Get English title for convenience."""
|
|
695
|
+
return self.title.en if self.title else None
|
|
696
|
+
|
|
697
|
+
@property
|
|
698
|
+
def text_en(self) -> str | None:
|
|
699
|
+
"""Get English text for convenience."""
|
|
700
|
+
return self.text.en if self.text else None
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
# ==================== Status Page Models ====================
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
class StatusPage(BaseModel):
|
|
707
|
+
"""Model for a status page response from v2 API.
|
|
708
|
+
|
|
709
|
+
API: GET /v2/statuspages, GET /v2/statuspages/{uuid}
|
|
710
|
+
"""
|
|
711
|
+
|
|
712
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
|
|
713
|
+
|
|
714
|
+
uuid: str = Field(..., description="Status page UUID")
|
|
715
|
+
name: str = Field(..., description="Status page display name")
|
|
716
|
+
subdomain: str = Field(..., description="Status page subdomain")
|
|
717
|
+
custom_domain: str | None = Field(
|
|
718
|
+
default=None, alias="customDomain", description="Custom domain"
|
|
719
|
+
)
|
|
720
|
+
public: bool = Field(default=True, description="Whether the page is publicly accessible")
|
|
721
|
+
monitors: list[str] = Field(
|
|
722
|
+
default_factory=list, description="Monitor UUIDs shown on this page"
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
class StatusPageCreate(BaseModel):
|
|
727
|
+
"""Model for creating a new status page.
|
|
728
|
+
|
|
729
|
+
API: POST /v2/statuspages
|
|
730
|
+
"""
|
|
731
|
+
|
|
732
|
+
model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
|
|
733
|
+
|
|
734
|
+
name: str = Field(..., min_length=1, max_length=255, description="Status page display name")
|
|
735
|
+
subdomain: str = Field(..., description="Status page subdomain")
|
|
736
|
+
custom_domain: str | None = Field(
|
|
737
|
+
default=None, alias="customDomain", description="Custom domain"
|
|
738
|
+
)
|
|
739
|
+
public: bool = Field(default=True, description="Whether the page is publicly accessible")
|
|
740
|
+
monitors: list[str] = Field(
|
|
741
|
+
default_factory=list, description="Monitor UUIDs shown on this page"
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
class StatusPageUpdate(BaseModel):
|
|
746
|
+
"""Model for updating an existing status page (all fields optional).
|
|
747
|
+
|
|
748
|
+
API: PUT /v2/statuspages/{uuid}
|
|
749
|
+
"""
|
|
750
|
+
|
|
751
|
+
model_config = ConfigDict(use_enum_values=True, populate_by_name=True)
|
|
752
|
+
|
|
753
|
+
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
754
|
+
subdomain: str | None = None
|
|
755
|
+
custom_domain: str | None = Field(default=None, alias="customDomain")
|
|
756
|
+
public: bool | None = None
|
|
757
|
+
monitors: list[str] | None = None
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
class StatusPageSubscriber(BaseModel):
|
|
761
|
+
"""Model for a status page subscriber.
|
|
762
|
+
|
|
763
|
+
API: GET /v2/statuspages/{uuid}/subscribers
|
|
764
|
+
"""
|
|
765
|
+
|
|
766
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, frozen=True)
|
|
767
|
+
|
|
768
|
+
id: str = Field(..., description="Subscriber ID")
|
|
769
|
+
email: str = Field(..., description="Subscriber email address")
|