DefEssential 0.1.7__21-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.
Files changed (104) hide show
  1. DefEssential/Adapter/Implements/Gunicorn/Server.py +152 -0
  2. DefEssential/Adapter/Implements/Gunicorn/Transport.py +171 -0
  3. DefEssential/Adapter/Implements/Gunicorn/__init__.py +5 -0
  4. DefEssential/Adapter/Implements/Python/Server.py +273 -0
  5. DefEssential/Adapter/Implements/Python/__init__.py +5 -0
  6. DefEssential/Adapter/Implements/Uvicorn/HTTP.py +256 -0
  7. DefEssential/Adapter/Implements/Uvicorn/Portal.py +65 -0
  8. DefEssential/Adapter/Implements/Uvicorn/Server.py +137 -0
  9. DefEssential/Adapter/Implements/Uvicorn/WebSocket.py +222 -0
  10. DefEssential/Adapter/Implements/Uvicorn/__init__.py +5 -0
  11. DefEssential/Adapter/Implements/__init__.py +3 -0
  12. DefEssential/Adapter/RequestHandler.py +109 -0
  13. DefEssential/Adapter/Server.py +54 -0
  14. DefEssential/Adapter/WSGI.py +219 -0
  15. DefEssential/Adapter/__init__.py +5 -0
  16. DefEssential/Application.py +657 -0
  17. DefEssential/Body.py +50 -0
  18. DefEssential/Content.py +18 -0
  19. DefEssential/Credentials.py +245 -0
  20. DefEssential/CrossOriginResourceSharing.py +107 -0
  21. DefEssential/Descriptor/DataObject.py +409 -0
  22. DefEssential/Descriptor/Types.py +248 -0
  23. DefEssential/Descriptor/Value.py +188 -0
  24. DefEssential/Descriptor/__init__.py +172 -0
  25. DefEssential/Error.py +66 -0
  26. DefEssential/Extension.py +191 -0
  27. DefEssential/Extensions/File.py +95 -0
  28. DefEssential/Extensions/FileSystem.py +195 -0
  29. DefEssential/Extensions/Metrics.py +510 -0
  30. DefEssential/Extensions/Ping.py +93 -0
  31. DefEssential/Extensions/Specifications.py +342 -0
  32. DefEssential/Extensions/Status.html +207 -0
  33. DefEssential/Extensions/Status.py +110 -0
  34. DefEssential/Extensions/Swagger.html +77 -0
  35. DefEssential/Extensions/Swagger.py +117 -0
  36. DefEssential/Extensions/Utils/Routes.py +42 -0
  37. DefEssential/Extensions/Utils/__init__.py +43 -0
  38. DefEssential/Extensions/__init__.py +21 -0
  39. DefEssential/IO/Readers/ChunkedStream.py +106 -0
  40. DefEssential/IO/Readers/ContentReader.py +36 -0
  41. DefEssential/IO/Readers/ContentReaders.py +241 -0
  42. DefEssential/IO/Readers/__init__.py +25 -0
  43. DefEssential/IO/RequestReader.py +125 -0
  44. DefEssential/IO/ResponseWriter.py +241 -0
  45. DefEssential/IO/WebSocket/WebSocket.py +286 -0
  46. DefEssential/IO/WebSocket/WebSocketError.py +17 -0
  47. DefEssential/IO/WebSocket/WebSocketFrame.py +68 -0
  48. DefEssential/IO/WebSocket/WebSocketFrameReader.py +98 -0
  49. DefEssential/IO/WebSocket/WebSocketHandshake.py +94 -0
  50. DefEssential/IO/WebSocket/__init__.py +13 -0
  51. DefEssential/IO/Writers/ChunkedStreamWriter.py +19 -0
  52. DefEssential/IO/Writers/ServerSentEvents.py +41 -0
  53. DefEssential/IO/Writers/ServerSentEventsWriter.py +67 -0
  54. DefEssential/IO/Writers/StreamWriter.py +57 -0
  55. DefEssential/IO/Writers/__init__.py +11 -0
  56. DefEssential/IO/__init__.py +7 -0
  57. DefEssential/Request.py +110 -0
  58. DefEssential/RequestRunner.py +261 -0
  59. DefEssential/Response.py +89 -0
  60. DefEssential/ResponseSpecification.py +23 -0
  61. DefEssential/Responses/ResponseFile.py +108 -0
  62. DefEssential/Responses/__init__.py +63 -0
  63. DefEssential/Route.py +55 -0
  64. DefEssential/Router.py +48 -0
  65. DefEssential/Runners/RunExtension.py +116 -0
  66. DefEssential/Runners/RunFactory.py +26 -0
  67. DefEssential/Runners/RunOptions.py +77 -0
  68. DefEssential/Runners/RunRequest.py +198 -0
  69. DefEssential/Runners/__init__.py +5 -0
  70. DefEssential/Startup.py +104 -0
  71. DefEssential/Utils/FileSystemObject/Configuration.py +15 -0
  72. DefEssential/Utils/FileSystemObject/Connection.py +57 -0
  73. DefEssential/Utils/FileSystemObject/File.py +41 -0
  74. DefEssential/Utils/FileSystemObject/Implements/FileSystem/Configuration.py +15 -0
  75. DefEssential/Utils/FileSystemObject/Implements/FileSystem/Connection.py +115 -0
  76. DefEssential/Utils/FileSystemObject/Implements/FileSystem/File.py +77 -0
  77. DefEssential/Utils/FileSystemObject/Implements/FileSystem/__init__.py +11 -0
  78. DefEssential/Utils/FileSystemObject/Implements/__init__.py +3 -0
  79. DefEssential/Utils/FileSystemObject/__init__.py +11 -0
  80. DefEssential/Utils/Loader.py +81 -0
  81. DefEssential/Utils/__init__.py +3 -0
  82. DefEssential/Validator/Pattern.py +36 -0
  83. DefEssential/Validator/Patterns/Array.py +30 -0
  84. DefEssential/Validator/Patterns/Common.py +92 -0
  85. DefEssential/Validator/Patterns/Compare.py +180 -0
  86. DefEssential/Validator/Patterns/Condition.py +60 -0
  87. DefEssential/Validator/Patterns/DataObject.py +85 -0
  88. DefEssential/Validator/Patterns/DateTime.py +111 -0
  89. DefEssential/Validator/Patterns/Number.py +43 -0
  90. DefEssential/Validator/Patterns/Object.py +150 -0
  91. DefEssential/Validator/Patterns/Size.py +110 -0
  92. DefEssential/Validator/Patterns/String.py +85 -0
  93. DefEssential/Validator/Patterns/Type.py +350 -0
  94. DefEssential/Validator/Patterns/__init__.py +137 -0
  95. DefEssential/Validator/Validate.py +29 -0
  96. DefEssential/Validator/Validator.py +30 -0
  97. DefEssential/Validator/__init__.py +11 -0
  98. DefEssential/Validators.py +255 -0
  99. DefEssential/__init__.py +83 -0
  100. defessential-0.1.7.dist-info/METADATA +41 -0
  101. defessential-0.1.7.dist-info/RECORD +104 -0
  102. defessential-0.1.7.dist-info/WHEEL +6 -0
  103. defessential-0.1.7.dist-info/licenses/LICENSE +674 -0
  104. defessential-0.1.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,93 @@
1
+ from logging import getLogger
2
+ from typing import (
3
+ Optional,
4
+ Sequence,
5
+ Union,
6
+ )
7
+
8
+ from ..Application import Application
9
+ from ..Extension import (
10
+ Extension,
11
+ ExtensionCleanup,
12
+ ExtensionRequest,
13
+ ExtensionRequestError,
14
+ ExtensionRequestResponse,
15
+ ExtensionStartup,
16
+ )
17
+ from ..Request import Request
18
+ from ..Response import Response
19
+ from ..Responses import ResponseJSON
20
+ from .Utils.Routes import (
21
+ http_routes,
22
+ validate_path,
23
+ )
24
+
25
+ __all__ = (
26
+ 'Ping',
27
+ )
28
+
29
+
30
+ class Ping(Extension):
31
+ """시작 시 연결한 Application의 현재 실행 상태를 응답한다."""
32
+
33
+ logger = getLogger('DefEssential.Extensions.Ping')
34
+
35
+ def __init__(
36
+ self,
37
+ path: str,
38
+ *,
39
+ on_startup: Optional[Union[
40
+ ExtensionStartup,
41
+ Sequence[ExtensionStartup],
42
+ ]] = None,
43
+ on_cleanup: Optional[Union[
44
+ ExtensionCleanup,
45
+ Sequence[ExtensionCleanup],
46
+ ]] = None,
47
+ on_request: Optional[Union[
48
+ ExtensionRequest,
49
+ Sequence[ExtensionRequest],
50
+ ]] = None,
51
+ on_request_response: Optional[Union[
52
+ ExtensionRequestResponse,
53
+ Sequence[ExtensionRequestResponse],
54
+ ]] = None,
55
+ on_request_error: Optional[Union[
56
+ ExtensionRequestError,
57
+ Sequence[ExtensionRequestError],
58
+ ]] = None,
59
+ ):
60
+ super().__init__(
61
+ on_startup=on_startup,
62
+ on_cleanup=on_cleanup,
63
+ on_request=on_request,
64
+ on_request_response=on_request_response,
65
+ on_request_error=on_request_error,
66
+ )
67
+ validate_path(path)
68
+ self.path = path
69
+ return
70
+
71
+ def __str__(self) -> str:
72
+ return '{}({})'.format(type(self).__name__, self.path)
73
+
74
+ def startup(
75
+ self,
76
+ application: Application,
77
+ ) -> None:
78
+ self.application = application
79
+ for route in http_routes(self, self.path):
80
+ application.add_extension_route(self, route)
81
+ super().startup(application)
82
+ return
83
+
84
+ def __call__(
85
+ self,
86
+ request: Request,
87
+ ) -> Response:
88
+ running = self.application.running
89
+ self.logger.debug('Application state observed: request_id=%s running=%s', request.id, running)
90
+ return ResponseJSON(
91
+ {'status': 'running' if running else 'stopped'},
92
+ status=200 if running else 503,
93
+ )
@@ -0,0 +1,342 @@
1
+ from collections.abc import (
2
+ Mapping,
3
+ Sequence,
4
+ )
5
+ from copy import deepcopy
6
+ from logging import getLogger
7
+ from time import monotonic
8
+ from re import (
9
+ fullmatch,
10
+ sub,
11
+ )
12
+ from typing import (
13
+ Any,
14
+ Optional,
15
+ Union,
16
+ )
17
+
18
+ from ..Application import Application
19
+ from ..Body import Body
20
+ from ..Content import Content
21
+ from ..Descriptor import (
22
+ Schema,
23
+ schema_value,
24
+ )
25
+ from ..Extension import (
26
+ Extension,
27
+ ExtensionCleanup,
28
+ ExtensionRequest,
29
+ ExtensionRequestError,
30
+ ExtensionRequestResponse,
31
+ ExtensionStartup,
32
+ )
33
+ from ..Request import Request
34
+ from ..IO.Readers import (
35
+ ByteStringContentReader,
36
+ FormUrlEncodedContentReader,
37
+ JavaScriptObjectNotationContentReader,
38
+ TextContentReader,
39
+ )
40
+ from ..Response import Response
41
+ from ..ResponseSpecification import ResponseSpecification
42
+ from ..Responses import ResponseJSON
43
+ from ..Validators import (
44
+ Auth,
45
+ Headers,
46
+ Parameters,
47
+ QueryString,
48
+ )
49
+ from .FileSystem import FileSystemRoute
50
+ from .Utils.Routes import (
51
+ http_routes,
52
+ validate_path,
53
+ )
54
+
55
+ __all__ = (
56
+ 'Specifications',
57
+ )
58
+
59
+ class Specifications(Extension):
60
+ """공유 라우트 목록의 현재 선언을 OpenAPI 3.1 문서로 변환한다."""
61
+
62
+ logger = getLogger('DefEssential.Extensions.Specifications')
63
+
64
+ def __init__(
65
+ self,
66
+ path: str,
67
+ *,
68
+ title: str = 'DefEssential',
69
+ version: str = '1.0.0',
70
+ description=None,
71
+ on_startup: Optional[Union[
72
+ ExtensionStartup,
73
+ Sequence[ExtensionStartup],
74
+ ]] = None,
75
+ on_cleanup: Optional[Union[
76
+ ExtensionCleanup,
77
+ Sequence[ExtensionCleanup],
78
+ ]] = None,
79
+ on_request: Optional[Union[
80
+ ExtensionRequest,
81
+ Sequence[ExtensionRequest],
82
+ ]] = None,
83
+ on_request_response: Optional[Union[
84
+ ExtensionRequestResponse,
85
+ Sequence[ExtensionRequestResponse],
86
+ ]] = None,
87
+ on_request_error: Optional[Union[
88
+ ExtensionRequestError,
89
+ Sequence[ExtensionRequestError],
90
+ ]] = None,
91
+ ):
92
+ super().__init__(
93
+ on_startup=on_startup,
94
+ on_cleanup=on_cleanup,
95
+ on_request=on_request,
96
+ on_request_response=on_request_response,
97
+ on_request_error=on_request_error,
98
+ )
99
+ validate_path(path)
100
+ self.path = path
101
+ if not isinstance(title, str) or not title or not isinstance(version, str) or not version:
102
+ raise ValueError('Document title and version must be non-empty strings')
103
+ self.title = title
104
+ self.version = version
105
+ self.description = description
106
+ return
107
+
108
+ def __str__(self) -> str:
109
+ return '{}({})'.format(type(self).__name__, self.path)
110
+
111
+ def startup(
112
+ self,
113
+ application: Application,
114
+ ) -> None:
115
+ self.application = application
116
+ for route in http_routes(self, self.path):
117
+ application.add_extension_route(self, route)
118
+ super().startup(application)
119
+ return
120
+
121
+ def document(
122
+ self,
123
+ ) -> dict:
124
+ started = monotonic()
125
+ self.logger.debug('Specification generation started')
126
+ schemas: dict[str, Any] = {}
127
+ security: dict[str, Any] = {}
128
+
129
+ def schema(value):
130
+ if isinstance(value, Schema):
131
+ name = value.name
132
+ converted = schema(value.format)
133
+ if name in schemas and schemas[name] != converted:
134
+ raise ValueError('Conflicting schemas with the same name: ' + name)
135
+ schemas[name] = converted
136
+ return {'$ref': '#/components/schemas/' + name.replace('~', '~0').replace('/', '~1')}
137
+ return schema_value(value)
138
+
139
+ def contents(value):
140
+ items = (value,) if isinstance(value, Content) else tuple(value or ())
141
+ result = {}
142
+ for item in items:
143
+ if not isinstance(item, Content):
144
+ raise TypeError('Body specifications require Content instances')
145
+ if item.format in result:
146
+ raise ValueError('Duplicate body format: ' + item.format)
147
+ entry = {}
148
+ if item.schema is not None:
149
+ entry['schema'] = schema(item.schema)
150
+ if item.example is not None:
151
+ entry['example'] = deepcopy(item.example)
152
+ result[item.format] = entry
153
+ return result
154
+
155
+ def responses(value):
156
+ if value is None:
157
+ return {'default': {'description': 'No response specification declared'}}
158
+ if isinstance(value, ResponseSpecification):
159
+ items = (value,)
160
+ elif isinstance(value, Mapping):
161
+ result = {}
162
+ for code, response in value.items():
163
+ if fullmatch(r'default|[1-5](?:[0-9]{2}|XX)', str(code)) is None:
164
+ raise ValueError('Invalid OpenAPI response status: ' + str(code))
165
+ if isinstance(response, str):
166
+ result[str(code)] = {'description': response}
167
+ elif isinstance(response, Mapping):
168
+ if not isinstance(response.get('description'), str):
169
+ raise ValueError('OpenAPI responses require a description')
170
+ result[str(code)] = deepcopy(dict(response))
171
+ else:
172
+ raise TypeError('Response mappings require description strings or OpenAPI response objects')
173
+ if not result:
174
+ raise ValueError('Response specification must not be empty')
175
+ return result
176
+ elif isinstance(value, Sequence) and not isinstance(value, str):
177
+ items = value
178
+ else:
179
+ raise TypeError('response must be a ResponseSpecification or response mapping')
180
+ result = {}
181
+ for item in items:
182
+ if not isinstance(item, ResponseSpecification):
183
+ raise TypeError('Response lists must contain ResponseSpecification instances')
184
+ if not 100 <= item.status <= 599:
185
+ raise ValueError('Invalid response status code')
186
+ code = str(item.status)
187
+ if code in result:
188
+ raise ValueError('Duplicate response status: ' + code)
189
+ entry: dict[str, Any] = {'description': item.description}
190
+ if item.content is not None:
191
+ entry['content'] = contents(item.content)
192
+ if item.headers is not None:
193
+ entry['headers'] = {
194
+ key: {'schema': schema(value)} for key, value in item.headers.items()
195
+ }
196
+ result[code] = entry
197
+ if not result:
198
+ raise ValueError('Response specification must not be empty')
199
+ return result
200
+
201
+ paths: dict[str, Any] = {}
202
+ custom: list[dict] = []
203
+ for route in self.application.router.routes:
204
+ if any(route is extension_route for extension_route in self.application.extension_routes):
205
+ continue
206
+ properties = route.object.properties
207
+ operation = {
208
+ 'operationId': route.method + ':' + route.url,
209
+ 'responses': responses(properties.response),
210
+ }
211
+ for name in (
212
+ 'summary',
213
+ 'description',
214
+ ):
215
+ value = getattr(properties, name)
216
+ if value is not None:
217
+ operation[name] = value
218
+ if properties.tags is not None:
219
+ operation['tags'] = [properties.tags] if isinstance(properties.tags, str) else list(properties.tags)
220
+ parameters = []
221
+ path_names = [match.group(1) for match in route.variable.finditer(route.url)]
222
+ if isinstance(route, FileSystemRoute):
223
+ path_names = ['path']
224
+ operation['x-defessential-catch-all'] = True
225
+ for location, check, names in (
226
+ ('path', properties.parameters, path_names),
227
+ ('query', properties.qs, ()),
228
+ ('header', properties.headers, ()),
229
+ ):
230
+ if isinstance(check, Parameters):
231
+ formats = check.format if check.format is not None else {}
232
+ keys = list(check.keys)
233
+ keys.extend(key for key in formats if key not in keys)
234
+ if isinstance(check, QueryString) and not isinstance(check, Headers):
235
+ keys.extend(key for key in check.required_keys if key not in keys)
236
+ keys.extend(name for name in names if name not in keys)
237
+ else:
238
+ formats = {}
239
+ keys = list(names)
240
+ for key in keys:
241
+ value = formats.get(key)
242
+ required = location == 'path'
243
+ if isinstance(check, QueryString):
244
+ required = key.lower() in check.required_keys if isinstance(check, Headers) else key in check.required_keys
245
+ entry = {
246
+ 'name': key,
247
+ 'in': location,
248
+ 'required': required,
249
+ 'schema': schema(value) if value is not None else {},
250
+ }
251
+ parameters.append(entry)
252
+ if parameters:
253
+ operation['parameters'] = parameters
254
+ if isinstance(properties.body, Body):
255
+ body_content = properties.body.content
256
+ if not body_content:
257
+ for reader, media in (
258
+ (JavaScriptObjectNotationContentReader, 'application/json'),
259
+ (FormUrlEncodedContentReader, 'application/x-www-form-urlencoded'),
260
+ (TextContentReader, 'text/*'),
261
+ (ByteStringContentReader, '*/*'),
262
+ ):
263
+ if isinstance(properties.body.reader, reader):
264
+ body_content = (Content(media),)
265
+ break
266
+ if not body_content:
267
+ raise ValueError('Custom readers require a Content specification: ' + route.url)
268
+ operation['requestBody'] = {
269
+ 'required': properties.body.required,
270
+ 'content': contents(body_content),
271
+ }
272
+ if isinstance(properties.auth, Auth):
273
+ credentials = properties.auth.credentials
274
+ definition = credentials.format
275
+ name = credentials.name
276
+ index = 2
277
+ while name in security and security[name] != definition:
278
+ name = credentials.name + str(index)
279
+ index += 1
280
+ security[name] = definition
281
+ operation['security'] = [{name: []}]
282
+ if properties.auth.optional:
283
+ operation['security'].append({})
284
+ path = sub(
285
+ r':([A-Za-z_][A-Za-z_0-9]*)',
286
+ r'{\1}',
287
+ route.url,
288
+ )
289
+ if isinstance(route, FileSystemRoute):
290
+ path = route.prefix + '{path}'
291
+ if route.method.lower() in (
292
+ 'get',
293
+ 'put',
294
+ 'post',
295
+ 'delete',
296
+ 'options',
297
+ 'head',
298
+ 'patch',
299
+ 'trace',
300
+ ):
301
+ path_item = paths.setdefault(path, {})
302
+ if route.method.lower() in path_item:
303
+ raise ValueError('Conflicting OpenAPI path and method: ' + route.method + ' ' + path)
304
+ path_item[route.method.lower()] = operation
305
+ else:
306
+ custom.append({
307
+ 'method': route.method,
308
+ 'path': path,
309
+ 'operation': operation,
310
+ })
311
+ info = {
312
+ 'title': self.title,
313
+ 'version': self.version,
314
+ }
315
+ if self.description is not None:
316
+ info['description'] = self.description
317
+ document = {
318
+ 'openapi': '3.1.0',
319
+ 'info': info,
320
+ 'paths': paths,
321
+ 'components': {
322
+ 'schemas': schemas,
323
+ 'securitySchemes': security,
324
+ },
325
+ }
326
+ if custom:
327
+ document['x-defessential-operations'] = custom
328
+ self.logger.debug(
329
+ 'Specification generated: routes=%s duration=%.6f',
330
+ len(self.application.router.routes),
331
+ monotonic() - started,
332
+ )
333
+ return document
334
+
335
+ def __call__(
336
+ self,
337
+ request: Request,
338
+ ) -> Response:
339
+ self.logger.debug('Specification response preparing: request_id=%s', request.id)
340
+ response = ResponseJSON(self.document())
341
+ self.logger.debug('Specification response prepared: request_id=%s', request.id)
342
+ return response
@@ -0,0 +1,207 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{title}} Status</title>
7
+ <style>
8
+ :root { color-scheme: light; font: 14px/1.55 system-ui, -apple-system, sans-serif; color: #263343; background: #f7f8fa; }
9
+ * { box-sizing: border-box; }
10
+ body { margin: 0; }
11
+ header { background: #fff; border-bottom: 1px solid #e1e5eb; }
12
+ .heading, main { max-width: 1440px; margin: auto; padding: 24px 32px; }
13
+ .heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
14
+ h1 { margin: 0; font-size: 22px; letter-spacing: -.6px; overflow-wrap: anywhere; }
15
+ h2 { margin: 0; font-size: 15px; }
16
+ p { margin: 6px 0 0; }
17
+ .subtitle, .muted { color: #657184; font-size: 12px; }
18
+ .subtitle:empty { display: none; }
19
+ .badge { padding: 5px 10px; border: 1px solid #c6dfcf; border-radius: 5px; color: #286f40; background: #f0faf3; font-size: 11px; white-space: nowrap; }
20
+ .toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
21
+ #notice { margin: 0; font-size: 12px; color: #536374; }
22
+ #error { color: #ab303d; font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
23
+ #error:empty { display: none; }
24
+ button { padding: 8px 14px; border: 1px solid #cdd5df; border-radius: 5px; font: 600 12px system-ui, sans-serif; background: #fff; color: #38485b; cursor: pointer; white-space: nowrap; }
25
+ button:hover { background: #edf3f8; }
26
+ select { padding: 8px; border: 1px solid #cdd5df; border-radius: 5px; background: #fff; color: #38485b; }
27
+ .controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
28
+ :focus-visible { outline: 3px solid #89b8ff; outline-offset: 3px; }
29
+ .cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 24px; }
30
+ .card, .panel { min-width: 0; background: #fff; border: 1px solid #e1e5eb; border-radius: 7px; }
31
+ .card { padding: 20px; }
32
+ .label { font-size: 12px; color: #657184; }
33
+ .value { margin-top: 8px; font-size: 28px; font-weight: 650; line-height: 1.3; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
34
+ .rate { color: #27834c; }
35
+ .failure { color: #b33b45; }
36
+ .hint { color: #768295; font-size: 11px; margin-top: 8px; }
37
+ .panel-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; padding: 18px 20px; border-bottom: 1px solid #e1e5eb; }
38
+ .table-scroll { overflow: auto; }
39
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
40
+ th { background: #f7f9fb; color: #657184; font-weight: 600; white-space: nowrap; }
41
+ th, td { padding: 13px 18px; text-align: right; border-bottom: 1px solid #edf0f4; font-variant-numeric: tabular-nums; }
42
+ th:first-child, td:first-child { text-align: left; }
43
+ td:first-child { max-width: 400px; overflow-wrap: anywhere; font: 12px/1.7 Consolas, monospace; }
44
+ tr:last-child td { border-bottom: 0; }
45
+ #empty { padding: 24px; color: #657184; font-size: 12px; }
46
+ .notes { margin-top: 20px; color: #657184; font-size: 12px; }
47
+ .stale .cards, .stale .panel { opacity: .55; }
48
+ [hidden] { display: none !important; }
49
+ @media(max-width: 800px) {
50
+ .heading, main { padding: 20px 16px; }
51
+ .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
52
+ .card { padding: 16px; }
53
+ .value { font-size: 24px; }
54
+ }
55
+ @media(max-width: 420px) {
56
+ .cards { grid-template-columns: 1fr; }
57
+ .heading { align-items: start; }
58
+ }
59
+ </style>
60
+ </head>
61
+ <body data-metrics-uri="{{metrics_uri}}">
62
+ <header><div class="heading">
63
+ <div><h1>{{title}} Status</h1><p class="subtitle">{{description}}</p></div>
64
+ <span class="badge">LIVE STATUS</span>
65
+ </div></header>
66
+ <main>
67
+ <div class="toolbar">
68
+ <div><p id="notice" role="status" aria-live="polite">Application에 연결하는 중입니다.</p><p id="error" role="alert"></p></div>
69
+ <div class="controls">
70
+ <label for="window">집계 구간</label><select id="window"><option value="1">최근 1초</option><option value="60">최근 1분</option></select>
71
+ <button id="pause" type="button" aria-pressed="false">갱신 일시정지</button>
72
+ </div>
73
+ </div>
74
+ <div class="cards">
75
+ <section class="card"><div class="label">구간 완료 요청</div><div id="rate" class="value rate">--</div><p id="window-hint" class="hint">완전히 지난 최근 1초의 완료 요청</p></section>
76
+ <section class="card"><div class="label">진행 중</div><div id="active" class="value">--</div><p class="hint">Application이 처리 중인 요청</p></section>
77
+ <section class="card"><div class="label">Application 상태</div><div id="state" class="value">--</div><p class="hint">실행 상태이며 외부 시스템 건강 검사는 아닙니다.</p></section>
78
+ <section class="card"><div class="label">누적 성공</div><div id="success" class="value">--</div><p class="hint">Application 시작 이후</p></section>
79
+ <section class="card"><div class="label">누적 실패</div><div id="failure" class="value failure">--</div><p class="hint">Application 처리 오류·HTTP 오류 응답 포함</p></section>
80
+ <section class="card"><div class="label">가동 시간</div><div id="uptime" class="value">--</div><p class="hint">Metrics 초기화 이후</p></section>
81
+ <section class="card"><div class="label">구간 성공 / 실패</div><div id="window-outcomes" class="value">--</div><p class="hint">선택한 구간 내 완료 요청</p></section>
82
+ <section class="card"><div class="label">구간 실패율</div><div id="failure-rate" class="value">--</div><p class="hint">실패 / 완료 요청</p></section>
83
+ <section class="card"><div class="label">구간 평균 시간</div><div id="average" class="value">--</div><p class="hint">Application 처리 완료까지 측정</p></section>
84
+ <section class="card"><div class="label">구간 최대 시간</div><div id="maximum" class="value">--</div><p class="hint">선택한 구간의 가장 느린 완료 요청</p></section>
85
+ <section class="card"><div class="label">최장 진행 시간</div><div id="oldest" class="value">--</div><p class="hint">Application에서 아직 반환하지 않은 요청</p></section>
86
+ </div>
87
+ <section class="panel" aria-labelledby="requests-title">
88
+ <div class="panel-heading"><h2 id="requests-title">요청 처리 현황</h2><span class="muted">구간 집계와 누적 합계</span></div>
89
+ <div class="table-scroll"><table>
90
+ <thead><tr><th scope="col">경로</th><th scope="col">진행 중</th><th scope="col">누적 성공</th><th scope="col">누적 실패</th><th id="rate-heading" scope="col">완료 / 초</th><th scope="col">구간 평균</th><th scope="col">구간 최대</th><th scope="col">구간 성공</th><th scope="col">구간 실패</th><th scope="col">실패율</th><th scope="col">최장 진행</th></tr></thead>
91
+ <tbody id="requests"></tbody>
92
+ </table></div>
93
+ <p id="empty" hidden>등록된 요청 지표가 없습니다.</p>
94
+ </section>
95
+ <p class="notes">약 1초 간격으로 갱신합니다. 집계 구간은 완전히 지난 monotonic 초를 기준으로 하므로 실시간 누적 합계보다 1초 미만 늦을 수 있습니다.
96
+ 최근 1분은 실제 60초의 완료 요청 수이며 초당 값을 곱한 추정치가 아닙니다. 시작 직후에는 시작 이후 데이터만 포함합니다.
97
+ 확장 자신의 요청도 측정하며, 하나의 Application에 대한 지표입니다. 응답 반환 후의 전송 시간·스트림 소비 오류는 포함하지 않습니다. 영구 이력은 저장하지 않습니다.</p>
98
+ </main>
99
+ <script>
100
+ 'use strict';
101
+ const el = id => document.getElementById(id);
102
+ const number = new Intl.NumberFormat(undefined, {maximumFractionDigits: 2});
103
+ let latest = null;
104
+ let timer = null;
105
+ let pending = false;
106
+ let paused = false;
107
+ const milliseconds = value => number.format(value * 1000) + ' ms';
108
+ const failureRate = period => period.success + period.failure ? number.format(period.failure / (period.success + period.failure) * 100) + '%' : '--';
109
+ function duration(seconds) {
110
+ const total = Math.floor(seconds);
111
+ return [Math.floor(total / 3600), Math.floor(total % 3600 / 60), total % 60].map(value => String(value).padStart(2, '0')).join(':');
112
+ }
113
+ function render(snapshot) {
114
+ const seconds = el('window').value;
115
+ const totals = {active: 0, success: 0, failure: 0};
116
+ const periodTotals = {success: 0, failure: 0, duration: 0, max_duration: 0};
117
+ let oldest = 0;
118
+ const rows = Object.entries(snapshot.requests).map(([path, stats]) => {
119
+ for (const key of Object.keys(totals)) totals[key] += stats[key];
120
+ const period = stats.windows[seconds];
121
+ for (const key of ['success', 'failure', 'duration']) periodTotals[key] += period[key];
122
+ periodTotals.max_duration = Math.max(periodTotals.max_duration, period.max_duration);
123
+ oldest = Math.max(oldest, stats.oldest_active);
124
+ const count = period.success + period.failure;
125
+ const row = document.createElement('tr');
126
+ const values = [
127
+ path, number.format(stats.active), number.format(stats.success), number.format(stats.failure),
128
+ number.format(count), count ? milliseconds(period.duration / count) : '--',
129
+ count ? milliseconds(period.max_duration) : '--', number.format(period.success), number.format(period.failure),
130
+ failureRate(period), stats.active ? milliseconds(stats.oldest_active) : '--'
131
+ ];
132
+ values.forEach((value, index) => {
133
+ const cell = document.createElement('td');
134
+ if (index >= 4) cell.className = 'request-rate';
135
+ cell.textContent = value;
136
+ row.append(cell);
137
+ });
138
+ return row;
139
+ });
140
+ el('requests').replaceChildren(...rows);
141
+ el('empty').hidden = rows.length > 0;
142
+ for (const key of Object.keys(totals)) el(key).textContent = number.format(totals[key]);
143
+ const count = periodTotals.success + periodTotals.failure;
144
+ el('rate').textContent = number.format(count) + (seconds === '60' ? ' /분' : ' /초');
145
+ el('window-hint').textContent = '완전히 지난 최근 ' + (seconds === '60' ? '60초' : '1초') + '의 완료 요청';
146
+ el('rate-heading').textContent = seconds === '60' ? '완료 / 분' : '완료 / 초';
147
+ el('window-outcomes').textContent = number.format(periodTotals.success) + ' / ' + number.format(periodTotals.failure);
148
+ el('failure-rate').textContent = failureRate(periodTotals);
149
+ el('average').textContent = count ? milliseconds(periodTotals.duration / count) : '--';
150
+ el('maximum').textContent = count ? milliseconds(periodTotals.max_duration) : '--';
151
+ el('oldest').textContent = totals.active ? milliseconds(oldest) : '--';
152
+ el('state').textContent = snapshot.running ? '실행 중' : '종료';
153
+ el('uptime').textContent = duration(snapshot.uptime);
154
+ latest = snapshot;
155
+ }
156
+ function stale() {
157
+ latest = null;
158
+ document.body.classList.add('stale');
159
+ for (const id of ['rate', 'window-outcomes', 'failure-rate', 'average', 'maximum', 'oldest']) el(id).textContent = '--';
160
+ for (const cell of document.querySelectorAll('.request-rate')) cell.textContent = '--';
161
+ }
162
+ el('window').onchange = () => { if (latest) render(latest); };
163
+ async function refresh() {
164
+ if (pending || paused || document.hidden) return;
165
+ pending = true;
166
+ const controller = new AbortController();
167
+ const timeout = setTimeout(() => controller.abort(), 5000);
168
+ try {
169
+ const response = await fetch(document.body.dataset.metricsUri, {
170
+ headers: {Accept: 'application/json'}, credentials: 'same-origin', redirect: 'error', cache: 'no-store', signal: controller.signal
171
+ });
172
+ if (!response.ok) throw new Error('HTTP ' + response.status);
173
+ const snapshot = await response.json();
174
+ if (paused || document.hidden) return;
175
+ render(snapshot);
176
+ document.body.classList.remove('stale');
177
+ el('error').textContent = '';
178
+ el('notice').textContent = '실시간 갱신 · ' + new Date().toLocaleTimeString();
179
+ } catch (failure) {
180
+ stale();
181
+ el('notice').textContent = '갱신 중단 · 다음 주기에 다시 시도합니다.';
182
+ el('error').textContent = 'Refresh failed. Displayed totals are stale. ' + (failure.name === 'AbortError' ? 'Request timed out.' : failure.message);
183
+ } finally {
184
+ clearTimeout(timeout);
185
+ pending = false;
186
+ if (!paused && !document.hidden) timer = setTimeout(refresh, 1000);
187
+ }
188
+ }
189
+ el('pause').onclick = () => {
190
+ paused = !paused;
191
+ clearTimeout(timer);
192
+ el('pause').textContent = paused ? '갱신 재개' : '갱신 일시정지';
193
+ el('pause').setAttribute('aria-pressed', String(paused));
194
+ if (paused) {
195
+ stale();
196
+ el('notice').textContent = '일시정지 · 표시된 데이터는 갱신되지 않습니다.';
197
+ } else refresh();
198
+ };
199
+ document.addEventListener('visibilitychange', () => {
200
+ clearTimeout(timer);
201
+ if (document.hidden) stale();
202
+ else refresh();
203
+ });
204
+ refresh();
205
+ </script>
206
+ </body>
207
+ </html>