servicekit 0.6.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.
- servicekit/.DS_Store +0 -0
- servicekit/__init__.py +88 -0
- servicekit/api/.DS_Store +0 -0
- servicekit/api/__init__.py +72 -0
- servicekit/api/app.py +225 -0
- servicekit/api/apps/.DS_Store +0 -0
- servicekit/api/apps/landing/index.html +146 -0
- servicekit/api/apps/landing/manifest.json +8 -0
- servicekit/api/auth.py +162 -0
- servicekit/api/crud.py +366 -0
- servicekit/api/dependencies.py +69 -0
- servicekit/api/middleware.py +163 -0
- servicekit/api/monitoring.py +100 -0
- servicekit/api/pagination.py +27 -0
- servicekit/api/registration.py +287 -0
- servicekit/api/router.py +28 -0
- servicekit/api/routers/__init__.py +18 -0
- servicekit/api/routers/health.py +114 -0
- servicekit/api/routers/job.py +126 -0
- servicekit/api/routers/metrics.py +47 -0
- servicekit/api/routers/system.py +77 -0
- servicekit/api/service_builder.py +793 -0
- servicekit/api/sse.py +23 -0
- servicekit/api/utilities.py +80 -0
- servicekit/database.py +262 -0
- servicekit/exceptions.py +138 -0
- servicekit/gunicorn.conf.py +109 -0
- servicekit/logging.py +98 -0
- servicekit/manager.py +311 -0
- servicekit/models.py +26 -0
- servicekit/py.typed +0 -0
- servicekit/repository.py +173 -0
- servicekit/scheduler.py +302 -0
- servicekit/schemas.py +184 -0
- servicekit/types.py +94 -0
- servicekit-0.6.0.dist-info/METADATA +185 -0
- servicekit-0.6.0.dist-info/RECORD +38 -0
- servicekit-0.6.0.dist-info/WHEEL +4 -0
servicekit/.DS_Store
ADDED
|
Binary file
|
servicekit/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Core framework components - generic interfaces and base classes."""
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: F401
|
|
4
|
+
|
|
5
|
+
# Read version from package metadata - must be before internal imports
|
|
6
|
+
try:
|
|
7
|
+
from importlib.metadata import version as _get_version
|
|
8
|
+
|
|
9
|
+
__version__ = _get_version("servicekit")
|
|
10
|
+
except Exception:
|
|
11
|
+
__version__ = "unknown"
|
|
12
|
+
|
|
13
|
+
# Base infrastructure (framework-agnostic)
|
|
14
|
+
from .database import Database, SqliteDatabase, SqliteDatabaseBuilder
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
BadRequestError,
|
|
17
|
+
ConflictError,
|
|
18
|
+
ErrorType,
|
|
19
|
+
ForbiddenError,
|
|
20
|
+
InvalidULIDError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
ServicekitException,
|
|
23
|
+
UnauthorizedError,
|
|
24
|
+
ValidationError,
|
|
25
|
+
)
|
|
26
|
+
from .logging import add_request_context, clear_request_context, configure_logging, get_logger, reset_request_context
|
|
27
|
+
from .manager import BaseManager, LifecycleHooks, Manager
|
|
28
|
+
from .models import Base, Entity
|
|
29
|
+
from .repository import BaseRepository, Repository
|
|
30
|
+
from .scheduler import InMemoryScheduler, Scheduler
|
|
31
|
+
from .schemas import (
|
|
32
|
+
BulkOperationError,
|
|
33
|
+
BulkOperationResult,
|
|
34
|
+
EntityIn,
|
|
35
|
+
EntityOut,
|
|
36
|
+
JobRecord,
|
|
37
|
+
JobStatus,
|
|
38
|
+
PaginatedResponse,
|
|
39
|
+
ProblemDetail,
|
|
40
|
+
)
|
|
41
|
+
from .types import JsonSafe, ULIDType
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
# Version
|
|
45
|
+
"__version__",
|
|
46
|
+
# Base infrastructure
|
|
47
|
+
"Database",
|
|
48
|
+
"SqliteDatabase",
|
|
49
|
+
"SqliteDatabaseBuilder",
|
|
50
|
+
"Repository",
|
|
51
|
+
"BaseRepository",
|
|
52
|
+
"Manager",
|
|
53
|
+
"LifecycleHooks",
|
|
54
|
+
"BaseManager",
|
|
55
|
+
# ORM and types
|
|
56
|
+
"Base",
|
|
57
|
+
"Entity",
|
|
58
|
+
"ULIDType",
|
|
59
|
+
"JsonSafe",
|
|
60
|
+
# Schemas
|
|
61
|
+
"EntityIn",
|
|
62
|
+
"EntityOut",
|
|
63
|
+
"PaginatedResponse",
|
|
64
|
+
"BulkOperationResult",
|
|
65
|
+
"BulkOperationError",
|
|
66
|
+
"ProblemDetail",
|
|
67
|
+
"JobRecord",
|
|
68
|
+
"JobStatus",
|
|
69
|
+
# Job scheduling
|
|
70
|
+
"Scheduler",
|
|
71
|
+
"InMemoryScheduler",
|
|
72
|
+
# Exceptions
|
|
73
|
+
"ErrorType",
|
|
74
|
+
"ServicekitException",
|
|
75
|
+
"NotFoundError",
|
|
76
|
+
"ValidationError",
|
|
77
|
+
"ConflictError",
|
|
78
|
+
"InvalidULIDError",
|
|
79
|
+
"BadRequestError",
|
|
80
|
+
"UnauthorizedError",
|
|
81
|
+
"ForbiddenError",
|
|
82
|
+
# Logging
|
|
83
|
+
"configure_logging",
|
|
84
|
+
"get_logger",
|
|
85
|
+
"add_request_context",
|
|
86
|
+
"clear_request_context",
|
|
87
|
+
"reset_request_context",
|
|
88
|
+
]
|
servicekit/api/.DS_Store
ADDED
|
Binary file
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""FastAPI framework layer - routers, middleware, utilities."""
|
|
2
|
+
|
|
3
|
+
from .app import App, AppInfo, AppLoader, AppManager, AppManifest
|
|
4
|
+
from .auth import APIKeyMiddleware, load_api_keys_from_env, load_api_keys_from_file, validate_api_key_format
|
|
5
|
+
from .crud import CrudPermissions, CrudRouter
|
|
6
|
+
from .dependencies import (
|
|
7
|
+
get_app_manager,
|
|
8
|
+
get_database,
|
|
9
|
+
get_scheduler,
|
|
10
|
+
get_session,
|
|
11
|
+
set_app_manager,
|
|
12
|
+
set_database,
|
|
13
|
+
set_scheduler,
|
|
14
|
+
)
|
|
15
|
+
from .middleware import add_error_handlers, add_logging_middleware, database_error_handler, validation_error_handler
|
|
16
|
+
from .pagination import PaginationParams, create_paginated_response
|
|
17
|
+
from .router import Router
|
|
18
|
+
from .routers import HealthRouter, HealthState, HealthStatus, JobRouter, SystemInfo, SystemRouter
|
|
19
|
+
from .service_builder import BaseServiceBuilder, ServiceInfo
|
|
20
|
+
from .sse import SSE_HEADERS, format_sse_event, format_sse_model_event
|
|
21
|
+
from .utilities import build_location_url, run_app
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
# Base router classes
|
|
25
|
+
"Router",
|
|
26
|
+
"CrudRouter",
|
|
27
|
+
"CrudPermissions",
|
|
28
|
+
# Service builder
|
|
29
|
+
"BaseServiceBuilder",
|
|
30
|
+
"ServiceInfo",
|
|
31
|
+
# App system
|
|
32
|
+
"App",
|
|
33
|
+
"AppInfo",
|
|
34
|
+
"AppLoader",
|
|
35
|
+
"AppManifest",
|
|
36
|
+
"AppManager",
|
|
37
|
+
# Authentication
|
|
38
|
+
"APIKeyMiddleware",
|
|
39
|
+
"load_api_keys_from_env",
|
|
40
|
+
"load_api_keys_from_file",
|
|
41
|
+
"validate_api_key_format",
|
|
42
|
+
# Dependencies
|
|
43
|
+
"get_app_manager",
|
|
44
|
+
"set_app_manager",
|
|
45
|
+
"get_database",
|
|
46
|
+
"set_database",
|
|
47
|
+
"get_session",
|
|
48
|
+
"get_scheduler",
|
|
49
|
+
"set_scheduler",
|
|
50
|
+
# Middleware
|
|
51
|
+
"add_error_handlers",
|
|
52
|
+
"add_logging_middleware",
|
|
53
|
+
"database_error_handler",
|
|
54
|
+
"validation_error_handler",
|
|
55
|
+
# Pagination
|
|
56
|
+
"PaginationParams",
|
|
57
|
+
"create_paginated_response",
|
|
58
|
+
# System routers
|
|
59
|
+
"HealthRouter",
|
|
60
|
+
"HealthState",
|
|
61
|
+
"HealthStatus",
|
|
62
|
+
"JobRouter",
|
|
63
|
+
"SystemRouter",
|
|
64
|
+
"SystemInfo",
|
|
65
|
+
# SSE utilities
|
|
66
|
+
"SSE_HEADERS",
|
|
67
|
+
"format_sse_event",
|
|
68
|
+
"format_sse_model_event",
|
|
69
|
+
# Utilities
|
|
70
|
+
"build_location_url",
|
|
71
|
+
"run_app",
|
|
72
|
+
]
|
servicekit/api/app.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""App system for hosting static web applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
11
|
+
|
|
12
|
+
from servicekit.logging import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AppManifest(BaseModel):
|
|
18
|
+
"""App manifest configuration."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(extra="forbid")
|
|
21
|
+
|
|
22
|
+
name: str = Field(description="Human-readable app name")
|
|
23
|
+
version: str = Field(description="Semantic version")
|
|
24
|
+
prefix: str = Field(description="URL prefix for mounting")
|
|
25
|
+
description: str | None = Field(default=None, description="App description")
|
|
26
|
+
author: str | None = Field(default=None, description="Author name")
|
|
27
|
+
entry: str = Field(default="index.html", description="Entry point filename")
|
|
28
|
+
|
|
29
|
+
@field_validator("prefix")
|
|
30
|
+
@classmethod
|
|
31
|
+
def validate_prefix(cls, v: str) -> str:
|
|
32
|
+
"""Validate mount prefix format."""
|
|
33
|
+
if not v.startswith("/"):
|
|
34
|
+
raise ValueError("prefix must start with '/'")
|
|
35
|
+
if ".." in v:
|
|
36
|
+
raise ValueError("prefix cannot contain '..'")
|
|
37
|
+
if v.startswith("/api/") or v == "/api":
|
|
38
|
+
raise ValueError("prefix cannot be '/api' or start with '/api/'")
|
|
39
|
+
return v
|
|
40
|
+
|
|
41
|
+
@field_validator("entry")
|
|
42
|
+
@classmethod
|
|
43
|
+
def validate_entry(cls, v: str) -> str:
|
|
44
|
+
"""Validate entry file path for security."""
|
|
45
|
+
if ".." in v:
|
|
46
|
+
raise ValueError("entry cannot contain '..'")
|
|
47
|
+
if v.startswith("/"):
|
|
48
|
+
raise ValueError("entry must be a relative path")
|
|
49
|
+
# Normalize and check for path traversal
|
|
50
|
+
normalized = Path(v).as_posix()
|
|
51
|
+
if normalized.startswith("../") or "/../" in normalized:
|
|
52
|
+
raise ValueError("entry cannot contain path traversal")
|
|
53
|
+
return v
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class App:
|
|
58
|
+
"""Represents a loaded app with manifest and directory."""
|
|
59
|
+
|
|
60
|
+
manifest: AppManifest
|
|
61
|
+
directory: Path
|
|
62
|
+
prefix: str # May differ from manifest if overridden
|
|
63
|
+
is_package: bool # True if loaded from package resources
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AppLoader:
|
|
67
|
+
"""Loads and validates apps from filesystem or package resources."""
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def load(path: str | Path | tuple[str, str], prefix: str | None = None) -> App:
|
|
71
|
+
"""Load and validate app from filesystem path or package resource tuple."""
|
|
72
|
+
# Detect source type and resolve to directory
|
|
73
|
+
if isinstance(path, tuple):
|
|
74
|
+
# Package resource
|
|
75
|
+
dir_path, is_package = AppLoader._resolve_package_path(path)
|
|
76
|
+
else:
|
|
77
|
+
# Filesystem path
|
|
78
|
+
dir_path = Path(path).resolve()
|
|
79
|
+
is_package = False
|
|
80
|
+
|
|
81
|
+
if not dir_path.exists():
|
|
82
|
+
raise FileNotFoundError(f"App directory not found: {dir_path}")
|
|
83
|
+
if not dir_path.is_dir():
|
|
84
|
+
raise NotADirectoryError(f"App path is not a directory: {dir_path}")
|
|
85
|
+
|
|
86
|
+
# Load and validate manifest
|
|
87
|
+
manifest_path = dir_path / "manifest.json"
|
|
88
|
+
if not manifest_path.exists():
|
|
89
|
+
raise FileNotFoundError(f"manifest.json not found in: {dir_path}")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
with manifest_path.open() as f:
|
|
93
|
+
manifest_data = json.load(f)
|
|
94
|
+
except json.JSONDecodeError as e:
|
|
95
|
+
raise ValueError(f"Invalid JSON in manifest.json: {e}") from e
|
|
96
|
+
|
|
97
|
+
manifest = AppManifest(**manifest_data)
|
|
98
|
+
|
|
99
|
+
# Validate entry file exists
|
|
100
|
+
entry_path = dir_path / manifest.entry
|
|
101
|
+
if not entry_path.exists():
|
|
102
|
+
raise FileNotFoundError(f"Entry file '{manifest.entry}' not found in: {dir_path}")
|
|
103
|
+
|
|
104
|
+
# Use override or manifest prefix
|
|
105
|
+
final_prefix = prefix if prefix is not None else manifest.prefix
|
|
106
|
+
|
|
107
|
+
# Re-validate prefix if overridden
|
|
108
|
+
if prefix is not None:
|
|
109
|
+
validated = AppManifest(
|
|
110
|
+
name=manifest.name,
|
|
111
|
+
version=manifest.version,
|
|
112
|
+
prefix=final_prefix,
|
|
113
|
+
)
|
|
114
|
+
final_prefix = validated.prefix
|
|
115
|
+
|
|
116
|
+
return App(
|
|
117
|
+
manifest=manifest,
|
|
118
|
+
directory=dir_path,
|
|
119
|
+
prefix=final_prefix,
|
|
120
|
+
is_package=is_package,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def discover(path: str | Path | tuple[str, str]) -> list[App]:
|
|
125
|
+
"""Discover all apps with manifest.json in directory."""
|
|
126
|
+
# Resolve directory
|
|
127
|
+
if isinstance(path, tuple):
|
|
128
|
+
dir_path, _ = AppLoader._resolve_package_path(path)
|
|
129
|
+
else:
|
|
130
|
+
dir_path = Path(path).resolve()
|
|
131
|
+
|
|
132
|
+
if not dir_path.exists():
|
|
133
|
+
raise FileNotFoundError(f"Apps directory not found: {dir_path}")
|
|
134
|
+
if not dir_path.is_dir():
|
|
135
|
+
raise NotADirectoryError(f"Apps path is not a directory: {dir_path}")
|
|
136
|
+
|
|
137
|
+
# Scan for subdirectories with manifest.json
|
|
138
|
+
apps: list[App] = []
|
|
139
|
+
for subdir in dir_path.iterdir():
|
|
140
|
+
if subdir.is_dir() and (subdir / "manifest.json").exists():
|
|
141
|
+
try:
|
|
142
|
+
# Determine if we're in a package context
|
|
143
|
+
if isinstance(path, tuple):
|
|
144
|
+
# Build tuple path for subdirectory
|
|
145
|
+
package_name: str = path[0]
|
|
146
|
+
base_path: str = path[1]
|
|
147
|
+
subdir_name = subdir.name
|
|
148
|
+
subpath = f"{base_path}/{subdir_name}" if base_path else subdir_name
|
|
149
|
+
app = AppLoader.load((package_name, subpath))
|
|
150
|
+
else:
|
|
151
|
+
app = AppLoader.load(subdir)
|
|
152
|
+
apps.append(app)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
# Log but don't fail discovery for invalid apps
|
|
155
|
+
logger.warning(
|
|
156
|
+
"app.discovery.failed",
|
|
157
|
+
directory=str(subdir),
|
|
158
|
+
error=str(e),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return apps
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _resolve_package_path(package_tuple: tuple[str, str]) -> tuple[Path, bool]:
|
|
165
|
+
"""Resolve package resource tuple to filesystem path."""
|
|
166
|
+
package_name, subpath = package_tuple
|
|
167
|
+
|
|
168
|
+
# Validate subpath for security
|
|
169
|
+
if ".." in subpath:
|
|
170
|
+
raise ValueError(f"subpath cannot contain '..' (got: {subpath})")
|
|
171
|
+
if subpath.startswith("/"):
|
|
172
|
+
raise ValueError(f"subpath must be relative (got: {subpath})")
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
spec = importlib.util.find_spec(package_name)
|
|
176
|
+
except (ModuleNotFoundError, ValueError) as e:
|
|
177
|
+
raise ValueError(f"Package '{package_name}' could not be found") from e
|
|
178
|
+
|
|
179
|
+
if spec is None or spec.origin is None:
|
|
180
|
+
raise ValueError(f"Package '{package_name}' could not be found")
|
|
181
|
+
|
|
182
|
+
# Resolve to package directory
|
|
183
|
+
package_dir = Path(spec.origin).parent
|
|
184
|
+
app_dir = package_dir / subpath
|
|
185
|
+
|
|
186
|
+
# Verify resolved path is still within package directory
|
|
187
|
+
try:
|
|
188
|
+
app_dir.resolve().relative_to(package_dir.resolve())
|
|
189
|
+
except ValueError as e:
|
|
190
|
+
raise ValueError(f"App path '{subpath}' escapes package directory") from e
|
|
191
|
+
|
|
192
|
+
if not app_dir.exists():
|
|
193
|
+
raise FileNotFoundError(f"App path '{subpath}' not found in package '{package_name}'")
|
|
194
|
+
if not app_dir.is_dir():
|
|
195
|
+
raise NotADirectoryError(f"App path '{subpath}' in package '{package_name}' is not a directory")
|
|
196
|
+
|
|
197
|
+
return app_dir, True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class AppInfo(BaseModel):
|
|
201
|
+
"""App metadata for API responses."""
|
|
202
|
+
|
|
203
|
+
name: str = Field(description="Human-readable app name")
|
|
204
|
+
version: str = Field(description="Semantic version")
|
|
205
|
+
prefix: str = Field(description="URL prefix for mounting")
|
|
206
|
+
description: str | None = Field(default=None, description="App description")
|
|
207
|
+
author: str | None = Field(default=None, description="Author name")
|
|
208
|
+
entry: str = Field(description="Entry point filename")
|
|
209
|
+
is_package: bool = Field(description="Whether app is loaded from package resources")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class AppManager:
|
|
213
|
+
"""Lightweight manager for app metadata queries."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, apps: list[App]):
|
|
216
|
+
"""Initialize with loaded apps."""
|
|
217
|
+
self._apps = apps
|
|
218
|
+
|
|
219
|
+
def list(self) -> list[App]:
|
|
220
|
+
"""Return all installed apps."""
|
|
221
|
+
return self._apps
|
|
222
|
+
|
|
223
|
+
def get(self, prefix: str) -> App | None:
|
|
224
|
+
"""Get app by mount prefix."""
|
|
225
|
+
return next((app for app in self._apps if app.prefix == prefix), None)
|
|
Binary file
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Service Information</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
10
|
+
max-width: 800px;
|
|
11
|
+
margin: 2rem auto;
|
|
12
|
+
padding: 0 1rem;
|
|
13
|
+
background: #f5f5f5;
|
|
14
|
+
}
|
|
15
|
+
.container {
|
|
16
|
+
background: white;
|
|
17
|
+
border-radius: 8px;
|
|
18
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
19
|
+
padding: 2rem;
|
|
20
|
+
}
|
|
21
|
+
h1 {
|
|
22
|
+
margin: 0 0 0.5rem 0;
|
|
23
|
+
color: #333;
|
|
24
|
+
}
|
|
25
|
+
.nav-links {
|
|
26
|
+
display: flex;
|
|
27
|
+
gap: 1rem;
|
|
28
|
+
margin-bottom: 1.5rem;
|
|
29
|
+
padding-bottom: 1rem;
|
|
30
|
+
border-bottom: 1px solid #e5e5e5;
|
|
31
|
+
}
|
|
32
|
+
.nav-links a {
|
|
33
|
+
color: #2563eb;
|
|
34
|
+
text-decoration: none;
|
|
35
|
+
font-size: 0.875rem;
|
|
36
|
+
}
|
|
37
|
+
.nav-links a:hover {
|
|
38
|
+
text-decoration: underline;
|
|
39
|
+
}
|
|
40
|
+
.info-grid {
|
|
41
|
+
display: grid;
|
|
42
|
+
gap: 1rem;
|
|
43
|
+
margin-top: 1.5rem;
|
|
44
|
+
}
|
|
45
|
+
.info-item {
|
|
46
|
+
display: grid;
|
|
47
|
+
gap: 0.25rem;
|
|
48
|
+
}
|
|
49
|
+
.info-label {
|
|
50
|
+
font-size: 0.75rem;
|
|
51
|
+
text-transform: uppercase;
|
|
52
|
+
color: #666;
|
|
53
|
+
font-weight: 600;
|
|
54
|
+
letter-spacing: 0.05em;
|
|
55
|
+
}
|
|
56
|
+
.info-value {
|
|
57
|
+
color: #333;
|
|
58
|
+
font-size: 1rem;
|
|
59
|
+
}
|
|
60
|
+
pre {
|
|
61
|
+
background: #f5f5f5;
|
|
62
|
+
padding: 1rem;
|
|
63
|
+
border-radius: 4px;
|
|
64
|
+
overflow-x: auto;
|
|
65
|
+
margin: 0;
|
|
66
|
+
font-size: 0.875rem;
|
|
67
|
+
}
|
|
68
|
+
.loading {
|
|
69
|
+
text-align: center;
|
|
70
|
+
color: #666;
|
|
71
|
+
padding: 2rem;
|
|
72
|
+
}
|
|
73
|
+
.error {
|
|
74
|
+
color: #dc2626;
|
|
75
|
+
padding: 1rem;
|
|
76
|
+
background: #fee;
|
|
77
|
+
border-radius: 4px;
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<div class="container">
|
|
83
|
+
<div id="content" class="loading">Loading service information...</div>
|
|
84
|
+
</div>
|
|
85
|
+
<script>
|
|
86
|
+
fetch('/api/v1/info')
|
|
87
|
+
.then(res => res.json())
|
|
88
|
+
.then(info => {
|
|
89
|
+
const items = [];
|
|
90
|
+
|
|
91
|
+
items.push(`<h1>${info.display_name}</h1>`);
|
|
92
|
+
items.push(`<div class="nav-links">
|
|
93
|
+
<a href="/docs">📚 API Documentation</a>
|
|
94
|
+
<a href="/redoc">📖 ReDoc</a>
|
|
95
|
+
</div>`);
|
|
96
|
+
items.push(`<div class="info-grid">`);
|
|
97
|
+
|
|
98
|
+
items.push(`<div class="info-item">
|
|
99
|
+
<div class="info-label">Version</div>
|
|
100
|
+
<div class="info-value">${info.version}</div>
|
|
101
|
+
</div>`);
|
|
102
|
+
|
|
103
|
+
if (info.summary) {
|
|
104
|
+
items.push(`<div class="info-item">
|
|
105
|
+
<div class="info-label">Summary</div>
|
|
106
|
+
<div class="info-value">${info.summary}</div>
|
|
107
|
+
</div>`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (info.description) {
|
|
111
|
+
items.push(`<div class="info-item">
|
|
112
|
+
<div class="info-label">Description</div>
|
|
113
|
+
<div class="info-value">${info.description}</div>
|
|
114
|
+
</div>`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (info.contact) {
|
|
118
|
+
const contactJson = JSON.stringify(info.contact, null, 2);
|
|
119
|
+
items.push(`<div class="info-item">
|
|
120
|
+
<div class="info-label">Contact</div>
|
|
121
|
+
<div class="info-value"><pre>${contactJson}</pre></div>
|
|
122
|
+
</div>`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (info.license_info) {
|
|
126
|
+
const licenseJson = JSON.stringify(info.license_info, null, 2);
|
|
127
|
+
items.push(`<div class="info-item">
|
|
128
|
+
<div class="info-label">License</div>
|
|
129
|
+
<div class="info-value"><pre>${licenseJson}</pre></div>
|
|
130
|
+
</div>`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
items.push(`</div>`);
|
|
134
|
+
const contentEl = document.getElementById('content');
|
|
135
|
+
contentEl.className = '';
|
|
136
|
+
contentEl.innerHTML = items.join('');
|
|
137
|
+
})
|
|
138
|
+
.catch(err => {
|
|
139
|
+
const errorMsg = 'Failed to load service information: ' + err.message;
|
|
140
|
+
const contentEl = document.getElementById('content');
|
|
141
|
+
contentEl.className = '';
|
|
142
|
+
contentEl.innerHTML = `<div class="error">${errorMsg}</div>`;
|
|
143
|
+
});
|
|
144
|
+
</script>
|
|
145
|
+
</body>
|
|
146
|
+
</html>
|