monitor-app 2.1.0__tar.gz → 2.3.0__tar.gz

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 (95) hide show
  1. {monitor_app-2.1.0 → monitor_app-2.3.0}/PKG-INFO +27 -4
  2. {monitor_app-2.1.0 → monitor_app-2.3.0}/README.md +26 -3
  3. monitor_app-2.3.0/monitor_app/api/deps.py +140 -0
  4. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/errors.py +7 -1
  5. monitor_app-2.3.0/monitor_app/api/routers/auth.py +51 -0
  6. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/crud.py +7 -5
  7. monitor_app-2.3.0/monitor_app/api/routers/pages.py +282 -0
  8. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/views.py +77 -24
  9. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/schemas.py +1 -0
  10. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/cli.py +52 -2
  11. monitor_app-2.3.0/monitor_app/db/ingest_state.py +49 -0
  12. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/db/registry.py +13 -1
  13. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/db/repository.py +10 -2
  14. monitor_app-2.3.0/monitor_app/integrations/__init__.py +53 -0
  15. monitor_app-2.3.0/monitor_app/integrations/netdiag.py +166 -0
  16. monitor_app-2.3.0/monitor_app/integrations/network_checker.py +192 -0
  17. monitor_app-2.3.0/monitor_app/integrations/procdiag.py +281 -0
  18. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/main.py +58 -6
  19. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/config.py +40 -3
  20. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/env.example +0 -1
  21. monitor_app-2.3.0/monitor_app/services/alert_scheduler.py +73 -0
  22. monitor_app-2.3.0/monitor_app/services/alert_service.py +203 -0
  23. monitor_app-2.3.0/monitor_app/services/connectors.py +439 -0
  24. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/crud_service.py +38 -8
  25. monitor_app-2.3.0/monitor_app/services/importer.py +182 -0
  26. monitor_app-2.3.0/monitor_app/services/ingest_watcher.py +138 -0
  27. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/kpi_service.py +22 -7
  28. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/notifiers/__init__.py +0 -3
  29. monitor_app-2.3.0/monitor_app/services/view_cache.py +39 -0
  30. monitor_app-2.3.0/monitor_app/services/view_service.py +107 -0
  31. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/settings/declarative.py +105 -0
  32. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/settings/runtime.py +8 -1
  33. monitor_app-2.3.0/monitor_app/web/static/css/app.css +571 -0
  34. monitor_app-2.3.0/monitor_app/web/static/css/dashboard.css +147 -0
  35. monitor_app-2.3.0/monitor_app/web/static/css/kiosk.css +189 -0
  36. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/js/alerts-ui.js +23 -30
  37. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/js/app.js +50 -63
  38. monitor_app-2.3.0/monitor_app/web/static/js/chart-view.js +195 -0
  39. monitor_app-2.3.0/monitor_app/web/static/js/dashboard.js +193 -0
  40. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/js/form.js +7 -0
  41. monitor_app-2.3.0/monitor_app/web/static/js/kiosk.js +220 -0
  42. monitor_app-2.3.0/monitor_app/web/static/js/kpis.js +104 -0
  43. monitor_app-2.3.0/monitor_app/web/static/js/table-render.js +87 -0
  44. monitor_app-2.3.0/monitor_app/web/static/js/theme.js +73 -0
  45. monitor_app-2.3.0/monitor_app/web/templates/base.html +110 -0
  46. monitor_app-2.3.0/monitor_app/web/templates/index.html +116 -0
  47. monitor_app-2.3.0/monitor_app/web/templates/kiosk.html +75 -0
  48. monitor_app-2.3.0/monitor_app/web/templates/login.html +49 -0
  49. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/templates/table.html +26 -6
  50. {monitor_app-2.1.0 → monitor_app-2.3.0}/pyproject.toml +25 -1
  51. monitor_app-2.1.0/monitor_app/api/deps.py +0 -88
  52. monitor_app-2.1.0/monitor_app/api/routers/pages.py +0 -111
  53. monitor_app-2.1.0/monitor_app/services/alert_service.py +0 -130
  54. monitor_app-2.1.0/monitor_app/services/importer.py +0 -114
  55. monitor_app-2.1.0/monitor_app/services/ingest_watcher.py +0 -74
  56. monitor_app-2.1.0/monitor_app/services/notifiers/line.py +0 -22
  57. monitor_app-2.1.0/monitor_app/services/view_service.py +0 -54
  58. monitor_app-2.1.0/monitor_app/web/static/css/app.css +0 -278
  59. monitor_app-2.1.0/monitor_app/web/static/css/kiosk.css +0 -100
  60. monitor_app-2.1.0/monitor_app/web/static/js/chart-view.js +0 -95
  61. monitor_app-2.1.0/monitor_app/web/static/js/kiosk.js +0 -131
  62. monitor_app-2.1.0/monitor_app/web/static/js/kpis.js +0 -57
  63. monitor_app-2.1.0/monitor_app/web/templates/base.html +0 -44
  64. monitor_app-2.1.0/monitor_app/web/templates/index.html +0 -55
  65. monitor_app-2.1.0/monitor_app/web/templates/kiosk.html +0 -53
  66. {monitor_app-2.1.0 → monitor_app-2.3.0}/LICENSE +0 -0
  67. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/__init__.py +0 -0
  68. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/__init__.py +0 -0
  69. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/__init__.py +0 -0
  70. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/alerts.py +0 -0
  71. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/audit.py +0 -0
  72. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/ingest.py +1 -1
  73. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/meta.py +1 -1
  74. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/db/__init__.py +0 -0
  75. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/db/engine.py +0 -0
  76. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/exceptions.py +0 -0
  77. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/logging_conf.py +0 -0
  78. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/README.md +0 -0
  79. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/csv/orders.csv +0 -0
  80. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/csv/products.csv +0 -0
  81. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/csv/users.csv +0 -0
  82. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/__init__.py +0 -0
  83. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/audit_service.py +0 -0
  84. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/coercion.py +0 -0
  85. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/notifiers/base.py +0 -0
  86. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/notifiers/email.py +0 -0
  87. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/notifiers/webhook.py +0 -0
  88. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/settings/__init__.py +0 -0
  89. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/settings/loader.py +0 -0
  90. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/css/bootstrap.min.css +0 -0
  91. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/img/background.webp +0 -0
  92. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/img/favicon.ico +0 -0
  93. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/img/logo.webp +0 -0
  94. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/js/chart.umd.min.js +0 -0
  95. {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/templates/form.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: monitor-app
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Drop in a CSV, write one config file, and get a live-updating Web UI + REST API. FastAPI-based data monitoring for shop floors and small teams.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -72,9 +72,12 @@ Monitor App is a FastAPI-based data monitoring tool designed for shop floors, la
72
72
  | Feature | What it does | How to enable |
73
73
  |---|---|---|
74
74
  | Auto data ingest | Watches `csv/` for changes; `POST /api/ingest/{table}` for sensors/PLC/MES | `MONITOR_INGEST_WATCH` / always on |
75
- | Threshold alerts | On-screen banner + beep, Webhook (Slack/Teams), LINE, e-mail; edge-triggered | `alerts=[AlertRule(...)]` |
75
+ | Threshold alerts | On-screen banner + browser notification, Webhook (Slack/Teams), e-mail; edge-triggered | `alerts=[AlertRule(...)]` |
76
76
  | Andon wallboard | Full-screen rotating views with a green/amber/red status light at `/kiosk` | `kiosk=KioskConfig(...)` |
77
77
  | Trend / SPC charts | Line & bar charts with UCL/LCL/target lines, out-of-control points highlighted | `ViewDef(chart=ChartDef(...))` |
78
+ | Dashboard | Home page shows mini-chart grid (columns=1–4) for chart-enabled views; non-chart views listed below | `dashboard=DashboardConfig(columns=2)` |
79
+ | Group navigation | Sidebar and view list support named groups; `ViewDef(group="...")` | `ViewDef(group=...)` |
80
+ | Dark theme | Toggle between light and dark; persisted in `localStorage["monitor-theme"]`; `<html data-theme="dark">` | theme-toggle button (☀️/🌙) |
78
81
  | KPI cards | Scalar SQL (counts, rates, OEE) as color-coded cards on the home page | `kpis={...}` |
79
82
  | Operator entry forms | Touch-friendly forms generated from your table schema at `/form/{table}` | `TableDef(form=FormDef(...))` |
80
83
  | Audit log | Who changed what and when, for every write | `MONITOR_AUDIT_ENABLED` |
@@ -172,7 +175,7 @@ MONITOR_WEBHOOK_URL=https://hooks.slack.com/services/...
172
175
 
173
176
  | Path | Description |
174
177
  |---|---|
175
- | `/` | Home: KPI cards + view list |
178
+ | `/` | Home: dashboard with mini-charts, KPI cards, and view list |
176
179
  | `/table/{view}` | Live table / chart page |
177
180
  | `/kiosk` | Andon wallboard (full screen, auto-rotating) |
178
181
  | `/form/{table}` | Operator entry form |
@@ -182,6 +185,24 @@ MONITOR_WEBHOOK_URL=https://hooks.slack.com/services/...
182
185
  | `/api/views/{view}` | View data (+ `/stream` for SSE, `/export` for CSV/Excel) |
183
186
  | `/api/kpis`, `/api/alerts`, `/api/audit`, `/api/schema`, `/api/health` | KPIs, active alerts, audit log, schema, health |
184
187
 
188
+ ## 🔌 External tool integrations (v2.2)
189
+
190
+ Connect procdiag, netdiag, and network-checker to Monitor App with a few lines — no changes to the external tools required (read-only, pull-based).
191
+
192
+ ```python
193
+ from monitor_app.integrations import procdiag, netdiag, network_checker
194
+
195
+ config = procdiag.attach(config, db_path="/opt/procdiag/data/procdiag.db")
196
+ config = netdiag.attach(config)
197
+ config = network_checker.attach(config, hosts=["plc-01", "gateway"])
198
+ ```
199
+
200
+ Each `attach()` call merges tables, views, KPIs, and alert rules into your existing config. Run `monitor-app sync-sources` to ingest on demand, or set `MONITOR_INGEST_WATCH=1` for continuous polling.
201
+
202
+ Full reference: [docs/integrations_v2_2.md](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/integrations_v2_2.md)
203
+
204
+ ---
205
+
185
206
  ## 🔄 Migrating from v0.x
186
207
 
187
208
  v2 was rebuilt on FastAPI + Pydantic. The legacy dict-style `config.py`
@@ -193,7 +214,9 @@ compatibility shim, but new projects should use `MonitorConfig`. Key changes:
193
214
  - API paths: `/api/<table>` → `/api/tables/{table}`, `/api/view/<v>` → `/api/views/{view}`
194
215
 
195
216
  Details: [redesign notes](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/redesign_v2.md) ·
196
- [v2.1 feature design](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/feature_expansion_v2.1.md)
217
+ [v2.1 feature design](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/feature_expansion_v2.1.md) ·
218
+ [v2.2 integrations](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/integrations_v2_2.md) ·
219
+ [v2.3 UI redesign](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/ui_redesign_v2_3.md)
197
220
 
198
221
  ## 🐳 Docker
199
222
 
@@ -27,9 +27,12 @@ Monitor App is a FastAPI-based data monitoring tool designed for shop floors, la
27
27
  | Feature | What it does | How to enable |
28
28
  |---|---|---|
29
29
  | Auto data ingest | Watches `csv/` for changes; `POST /api/ingest/{table}` for sensors/PLC/MES | `MONITOR_INGEST_WATCH` / always on |
30
- | Threshold alerts | On-screen banner + beep, Webhook (Slack/Teams), LINE, e-mail; edge-triggered | `alerts=[AlertRule(...)]` |
30
+ | Threshold alerts | On-screen banner + browser notification, Webhook (Slack/Teams), e-mail; edge-triggered | `alerts=[AlertRule(...)]` |
31
31
  | Andon wallboard | Full-screen rotating views with a green/amber/red status light at `/kiosk` | `kiosk=KioskConfig(...)` |
32
32
  | Trend / SPC charts | Line & bar charts with UCL/LCL/target lines, out-of-control points highlighted | `ViewDef(chart=ChartDef(...))` |
33
+ | Dashboard | Home page shows mini-chart grid (columns=1–4) for chart-enabled views; non-chart views listed below | `dashboard=DashboardConfig(columns=2)` |
34
+ | Group navigation | Sidebar and view list support named groups; `ViewDef(group="...")` | `ViewDef(group=...)` |
35
+ | Dark theme | Toggle between light and dark; persisted in `localStorage["monitor-theme"]`; `<html data-theme="dark">` | theme-toggle button (☀️/🌙) |
33
36
  | KPI cards | Scalar SQL (counts, rates, OEE) as color-coded cards on the home page | `kpis={...}` |
34
37
  | Operator entry forms | Touch-friendly forms generated from your table schema at `/form/{table}` | `TableDef(form=FormDef(...))` |
35
38
  | Audit log | Who changed what and when, for every write | `MONITOR_AUDIT_ENABLED` |
@@ -127,7 +130,7 @@ MONITOR_WEBHOOK_URL=https://hooks.slack.com/services/...
127
130
 
128
131
  | Path | Description |
129
132
  |---|---|
130
- | `/` | Home: KPI cards + view list |
133
+ | `/` | Home: dashboard with mini-charts, KPI cards, and view list |
131
134
  | `/table/{view}` | Live table / chart page |
132
135
  | `/kiosk` | Andon wallboard (full screen, auto-rotating) |
133
136
  | `/form/{table}` | Operator entry form |
@@ -137,6 +140,24 @@ MONITOR_WEBHOOK_URL=https://hooks.slack.com/services/...
137
140
  | `/api/views/{view}` | View data (+ `/stream` for SSE, `/export` for CSV/Excel) |
138
141
  | `/api/kpis`, `/api/alerts`, `/api/audit`, `/api/schema`, `/api/health` | KPIs, active alerts, audit log, schema, health |
139
142
 
143
+ ## 🔌 External tool integrations (v2.2)
144
+
145
+ Connect procdiag, netdiag, and network-checker to Monitor App with a few lines — no changes to the external tools required (read-only, pull-based).
146
+
147
+ ```python
148
+ from monitor_app.integrations import procdiag, netdiag, network_checker
149
+
150
+ config = procdiag.attach(config, db_path="/opt/procdiag/data/procdiag.db")
151
+ config = netdiag.attach(config)
152
+ config = network_checker.attach(config, hosts=["plc-01", "gateway"])
153
+ ```
154
+
155
+ Each `attach()` call merges tables, views, KPIs, and alert rules into your existing config. Run `monitor-app sync-sources` to ingest on demand, or set `MONITOR_INGEST_WATCH=1` for continuous polling.
156
+
157
+ Full reference: [docs/integrations_v2_2.md](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/integrations_v2_2.md)
158
+
159
+ ---
160
+
140
161
  ## 🔄 Migrating from v0.x
141
162
 
142
163
  v2 was rebuilt on FastAPI + Pydantic. The legacy dict-style `config.py`
@@ -148,7 +169,9 @@ compatibility shim, but new projects should use `MonitorConfig`. Key changes:
148
169
  - API paths: `/api/<table>` → `/api/tables/{table}`, `/api/view/<v>` → `/api/views/{view}`
149
170
 
150
171
  Details: [redesign notes](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/redesign_v2.md) ·
151
- [v2.1 feature design](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/feature_expansion_v2.1.md)
172
+ [v2.1 feature design](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/feature_expansion_v2.1.md) ·
173
+ [v2.2 integrations](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/integrations_v2_2.md) ·
174
+ [v2.3 UI redesign](https://github.com/mikawa-bushi/monitor-app/blob/main/docs/ui_redesign_v2_3.md)
152
175
 
153
176
  ## 🐳 Docker
154
177
 
@@ -0,0 +1,140 @@
1
+ """FastAPI 依存。アプリ状態(app.state)へのアクセスと API キー / セッション検証。
2
+
3
+ 認証(#11): 書き込み系は ``api_key`` 設定時に必須、読み取り系は ``protect_reads``
4
+ 有効時に必須。クライアントは **X-API-Key ヘッダ** か **セッション Cookie** の
5
+ どちらかで認証できる。Cookie は同梱 Web UI がブラウザから認証するための経路で、
6
+ ``/api/login`` がキー検証後に発行する(値はキーの HMAC で、生キーは保存しない)。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+ import secrets
14
+
15
+ from fastapi import Depends, HTTPException, Request, Security
16
+ from fastapi.security import APIKeyHeader
17
+
18
+ from ..services.crud_service import CrudService
19
+ from ..services.view_service import ViewService
20
+ from ..settings.declarative import MonitorConfig
21
+ from ..settings.runtime import AppSettings
22
+
23
+ _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
24
+
25
+ #: セッション Cookie 名(#11)。
26
+ SESSION_COOKIE = "monitor_session"
27
+
28
+
29
+ def session_token(api_key: str) -> str:
30
+ """API キーから導出する署名付きセッショントークン。
31
+
32
+ 生キーを Cookie に置かないための HMAC。ステートレスなので再起動・多ワーカーでも
33
+ 一貫して検証できる。"""
34
+ return hmac.new(
35
+ api_key.encode("utf-8"), b"monitor-session", hashlib.sha256
36
+ ).hexdigest()
37
+
38
+
39
+ def get_settings(request: Request) -> AppSettings:
40
+ return request.app.state.settings
41
+
42
+
43
+ def get_config(request: Request) -> MonitorConfig:
44
+ return request.app.state.config
45
+
46
+
47
+ def get_crud_service(request: Request) -> CrudService:
48
+ return request.app.state.crud_service
49
+
50
+
51
+ def get_view_service(request: Request) -> ViewService:
52
+ return request.app.state.view_service
53
+
54
+
55
+ def get_alert_engine(request: Request):
56
+ """AlertEngine を返す(フェーズ1・A)。"""
57
+ return request.app.state.alert_engine
58
+
59
+
60
+ def get_kpi_service(request: Request):
61
+ """KpiService を返す(フェーズ2・E)。"""
62
+ return request.app.state.kpi_service
63
+
64
+
65
+ def get_audit_service(request: Request):
66
+ """AuditService を返す(フェーズ4・G)。"""
67
+ return request.app.state.audit_service
68
+
69
+
70
+ def _is_authed(settings: AppSettings, request: Request, provided: str | None) -> bool:
71
+ """ヘッダ or セッション Cookie で認証済みか。api_key 未設定なら常に True。"""
72
+ if settings.api_key is None:
73
+ return True
74
+ if provided and secrets.compare_digest(provided, settings.api_key):
75
+ return True
76
+ cookie = request.cookies.get(SESSION_COOKIE)
77
+ if cookie and secrets.compare_digest(cookie, session_token(settings.api_key)):
78
+ return True
79
+ return False
80
+
81
+
82
+ def get_actor(
83
+ request: Request,
84
+ provided: str | None = Security(_api_key_header),
85
+ ) -> str:
86
+ """監査用の実行主体を表す文字列(フェーズ4・G)。
87
+
88
+ ヘッダ認証なら "api"、Cookie セッションなら "ui"、いずれも無ければ "anon"。
89
+ クライアント IP を添える。
90
+ """
91
+ client = request.client.host if request.client else "unknown"
92
+ if provided:
93
+ who = "api"
94
+ elif request.cookies.get(SESSION_COOKIE):
95
+ who = "ui"
96
+ else:
97
+ who = "anon"
98
+ return f"{who}@{client}"
99
+
100
+
101
+ def _check_auth(settings: AppSettings, request: Request, provided: str | None) -> None:
102
+ if not _is_authed(settings, request, provided):
103
+ raise HTTPException(
104
+ status_code=401,
105
+ detail={"code": "UNAUTHORIZED", "message": "API キーが無効です"},
106
+ )
107
+
108
+
109
+ def require_write_auth(
110
+ request: Request,
111
+ settings: AppSettings = Depends(get_settings),
112
+ provided: str | None = Security(_api_key_header),
113
+ ) -> None:
114
+ """書き込み系(POST/PUT/DELETE)で必須。api_key 未設定なら素通り。"""
115
+ _check_auth(settings, request, provided)
116
+
117
+
118
+ def require_read_auth(
119
+ request: Request,
120
+ settings: AppSettings = Depends(get_settings),
121
+ provided: str | None = Security(_api_key_header),
122
+ ) -> None:
123
+ """読み取り系。protect_reads=True かつ api_key 設定時のみ検証。"""
124
+ if settings.protect_reads:
125
+ _check_auth(settings, request, provided)
126
+
127
+
128
+ class RedirectToLogin(Exception):
129
+ """HTML ページが未認証のとき送出。/login へのリダイレクトに変換される(#11)。"""
130
+
131
+
132
+ def page_auth_guard(
133
+ request: Request,
134
+ settings: AppSettings = Depends(get_settings),
135
+ provided: str | None = Security(_api_key_header),
136
+ ) -> None:
137
+ """HTML ページ用ガード。protect_reads+api_key 有効かつ未認証なら /login へ。"""
138
+ if settings.protect_reads and settings.api_key:
139
+ if not _is_authed(settings, request, provided):
140
+ raise RedirectToLogin()
@@ -6,9 +6,10 @@ import logging
6
6
  import uuid
7
7
 
8
8
  from fastapi import FastAPI, Request
9
- from fastapi.responses import JSONResponse
9
+ from fastapi.responses import JSONResponse, RedirectResponse
10
10
 
11
11
  from ..exceptions import MonitorAppError
12
+ from .deps import RedirectToLogin
12
13
 
13
14
  logger = logging.getLogger("monitor_app.api")
14
15
 
@@ -31,3 +32,8 @@ def register_exception_handlers(app: FastAPI) -> None:
31
32
  return JSONResponse(
32
33
  status_code=exc.status_code, content=_body(exc.code, exc.message)
33
34
  )
35
+
36
+ @app.exception_handler(RedirectToLogin)
37
+ async def _handle_login_redirect(_request: Request, _exc: RedirectToLogin):
38
+ # 未認証の HTML ページアクセスはログインページへ誘導する(#11)。
39
+ return RedirectResponse(url="/login", status_code=303)
@@ -0,0 +1,51 @@
1
+ """UI 向けセッション認証エンドポイント(#11)。
2
+
3
+ ``protect_reads``/``api_key`` 有効時に、ブラウザからキーを渡して同梱 UI を使える
4
+ ようにする。検証成功で HttpOnly な署名付き Cookie を発行し、以後の同一オリジン
5
+ fetch が自動的に認証される。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import secrets
11
+
12
+ from fastapi import APIRouter, Depends, Form, Request
13
+ from fastapi.responses import RedirectResponse
14
+
15
+ from ...settings.runtime import AppSettings
16
+ from ..deps import SESSION_COOKIE, get_settings, session_token
17
+
18
+ router = APIRouter(prefix="/api", tags=["Auth"])
19
+
20
+
21
+ @router.post("/login")
22
+ def login(
23
+ request: Request,
24
+ key: str = Form(...),
25
+ settings: AppSettings = Depends(get_settings),
26
+ ):
27
+ """キーを検証し、成功なら HttpOnly セッション Cookie を発行して / へ。"""
28
+ if settings.api_key is not None and not secrets.compare_digest(
29
+ key, settings.api_key
30
+ ):
31
+ return RedirectResponse(url="/login?error=1", status_code=303)
32
+
33
+ resp = RedirectResponse(url="/", status_code=303)
34
+ if settings.api_key is not None:
35
+ resp.set_cookie(
36
+ SESSION_COOKIE,
37
+ session_token(settings.api_key),
38
+ httponly=True,
39
+ samesite="lax",
40
+ secure=request.url.scheme == "https",
41
+ max_age=60 * 60 * 12, # 12 時間
42
+ )
43
+ return resp
44
+
45
+
46
+ @router.post("/logout")
47
+ def logout():
48
+ """セッション Cookie を失効させ、ログインページへ。"""
49
+ resp = RedirectResponse(url="/login", status_code=303)
50
+ resp.delete_cookie(SESSION_COOKIE)
51
+ return resp
@@ -2,10 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Dict
5
+ from typing import Any, Dict, Optional
6
6
 
7
- from fastapi import APIRouter, Body, Depends
7
+ from fastapi import APIRouter, Body, Depends, Query
8
8
 
9
+ from ...services.crud_service import CrudService
9
10
  from ..deps import (
10
11
  get_actor,
11
12
  get_crud_service,
@@ -13,7 +14,6 @@ from ..deps import (
13
14
  require_write_auth,
14
15
  )
15
16
  from ..schemas import MessageResponse, RecordResponse, TableDataResponse
16
- from ...services.crud_service import CrudService
17
17
 
18
18
  router = APIRouter(prefix="/api/tables", tags=["CRUD"])
19
19
 
@@ -21,11 +21,13 @@ router = APIRouter(prefix="/api/tables", tags=["CRUD"])
21
21
  @router.get("/{table_name}", response_model=TableDataResponse)
22
22
  def list_records(
23
23
  table_name: str,
24
+ limit: Optional[int] = Query(None, ge=1, description="返す最大行数(#17)"),
25
+ offset: int = Query(0, ge=0, description="先頭からのスキップ行数(#17)"),
24
26
  service: CrudService = Depends(get_crud_service),
25
27
  _: None = Depends(require_read_auth),
26
28
  ):
27
- """テーブルの全レコードを取得する。"""
28
- rows = service.list_records(table_name)
29
+ """テーブルのレコードを取得する。``limit``/``offset`` 省略時は全件。"""
30
+ rows = service.list_records(table_name, limit=limit, offset=offset)
29
31
  columns = service.config.tables[table_name].column_names
30
32
  return TableDataResponse(
31
33
  table_name=table_name, columns=columns, data=rows, count=len(rows)
@@ -0,0 +1,282 @@
1
+ """HTML ページ配信(ホーム・ビュー表示)。Jinja2 テンプレートを使う。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from fastapi import APIRouter, Depends, Request
8
+ from fastapi.responses import HTMLResponse
9
+
10
+ from ...exceptions import TableNotFoundError, ViewNotFoundError
11
+ from ...settings.declarative import MonitorConfig
12
+ from ..deps import get_config, page_auth_guard
13
+
14
+ router = APIRouter(tags=["Pages"], include_in_schema=False)
15
+
16
+
17
+ def _escape_json_for_script(raw: str) -> str:
18
+ """``<script type="application/json">`` 内へ安全に埋め込める JSON 文字列を返す(#S1)。
19
+
20
+ ``json.dumps(ensure_ascii=False)`` / Pydantic の ``model_dump_json`` は ``<`` ``>``
21
+ ``&`` を素通しするため、ビュータイトル等に ``</script>`` が含まれると script タグを
22
+ 閉じて DOM 破壊・XSS になり得る。HTML で意味を持つ文字と行区切り(JS では文字列を
23
+ 壊す U+2028/U+2029)を ``\\uXXXX`` へ置換する。``JSON.parse`` が元の文字へ復号する
24
+ ため、埋め込まれるデータの意味は変わらない。
25
+ """
26
+ return (
27
+ raw.replace("<", "\\u003c")
28
+ .replace(">", "\\u003e")
29
+ .replace("&", "\\u0026")
30
+ .replace("\u2028", "\\u2028")
31
+ .replace("\u2029", "\\u2029")
32
+ )
33
+
34
+
35
+ def _nav_groups(config: MonitorConfig) -> list[dict]:
36
+ """ナビゲーション用グループリストを返す。
37
+
38
+ config.views の出現順でグループ化する。group=="" のビューは、他にグループが
39
+ 存在する場合のみ「その他」として末尾に集約する。全ビューが group=="" の場合は
40
+ group 名 "" の単一グループを返す。
41
+ """
42
+ named: dict[str, list[dict]] = {} # group 名 → ビューリスト(出現順)
43
+ ungrouped: list[dict] = []
44
+
45
+ for vname, vdef in config.views.items():
46
+ entry = {"name": vname, "title": vdef.title or vname}
47
+ if vdef.group:
48
+ if vdef.group not in named:
49
+ named[vdef.group] = []
50
+ named[vdef.group].append(entry)
51
+ else:
52
+ ungrouped.append(entry)
53
+
54
+ # グループが一切ない場合は単一の無名グループを返す
55
+ if not named:
56
+ return [{"group": "", "views": ungrouped}]
57
+
58
+ result = [{"group": g, "views": vs} for g, vs in named.items()]
59
+ if ungrouped:
60
+ result.append({"group": "その他", "views": ungrouped})
61
+ return result
62
+
63
+
64
+ def _nav_groups_filtered(
65
+ config: MonitorConfig, *, chart: bool | None = None
66
+ ) -> list[dict]:
67
+ """nav_groups と同形式で、chart の有無でビューを絞り込んで返す。
68
+
69
+ chart=True → chart 付きビューのみ
70
+ chart=False → chart なしビューのみ
71
+ chart=None → 全ビュー(_nav_groups と同じ)
72
+ """
73
+ named: dict[str, list[dict]] = {}
74
+ ungrouped: list[dict] = []
75
+
76
+ for vname, vdef in config.views.items():
77
+ if chart is True and vdef.chart is None:
78
+ continue
79
+ if chart is False and vdef.chart is not None:
80
+ continue
81
+ entry = {"name": vname, "title": vdef.title or vname}
82
+ if vdef.group:
83
+ if vdef.group not in named:
84
+ named[vdef.group] = []
85
+ named[vdef.group].append(entry)
86
+ else:
87
+ ungrouped.append(entry)
88
+
89
+ if not named:
90
+ return [{"group": "", "views": ungrouped}]
91
+
92
+ result = [{"group": g, "views": vs} for g, vs in named.items()]
93
+ if ungrouped:
94
+ result.append({"group": "その他", "views": ungrouped})
95
+ return result
96
+
97
+
98
+ def _ctx(config: MonitorConfig, **extra) -> dict:
99
+ return {
100
+ "app_title": config.app_title,
101
+ "header_text": config.header_text,
102
+ "footer_text": config.footer_text,
103
+ "favicon_path": config.favicon_path,
104
+ "nav_groups": _nav_groups(config),
105
+ **extra,
106
+ }
107
+
108
+
109
+ @router.get("/", response_class=HTMLResponse)
110
+ def index(
111
+ request: Request,
112
+ config: MonitorConfig = Depends(get_config),
113
+ _: None = Depends(page_auth_guard),
114
+ ):
115
+ """ビュー一覧を表示するホームページ。"""
116
+ templates = request.app.state.templates
117
+
118
+ # ダッシュボード用: dashboard.views 指定がある場合はその順、なければ chart 付き全ビュー
119
+ if config.dashboard.views:
120
+ dash_views = [
121
+ {
122
+ "name": vname,
123
+ "title": config.views[vname].title or vname,
124
+ "chart": config.views[vname].chart.model_dump(),
125
+ }
126
+ for vname in config.dashboard.views
127
+ ]
128
+ else:
129
+ dash_views = [
130
+ {
131
+ "name": vname,
132
+ "title": vdef.title or vname,
133
+ "chart": vdef.chart.model_dump(),
134
+ }
135
+ for vname, vdef in config.views.items()
136
+ if vdef.chart is not None
137
+ ]
138
+ dashboard_json = _escape_json_for_script(
139
+ json.dumps(
140
+ {"columns": config.dashboard.columns, "views": dash_views},
141
+ ensure_ascii=False,
142
+ )
143
+ )
144
+
145
+ # chart を持たないビューのみ nav_groups と同形式で返す(リンク一覧用)
146
+ list_views = _nav_groups_filtered(config, chart=False)
147
+
148
+ return templates.TemplateResponse(
149
+ request,
150
+ "index.html",
151
+ _ctx(
152
+ config,
153
+ views=list(config.views.keys()),
154
+ has_kiosk=config.kiosk is not None,
155
+ has_kpis=bool(config.kpis),
156
+ refresh_interval_ms=config.refresh_interval_ms,
157
+ form_tables=[
158
+ name for name, t in config.tables.items() if t.form is not None
159
+ ],
160
+ dashboard_json=dashboard_json,
161
+ list_views=list_views,
162
+ ),
163
+ )
164
+
165
+
166
+ @router.get("/form/{table_name}", response_class=HTMLResponse)
167
+ def show_form(
168
+ request: Request,
169
+ table_name: str,
170
+ config: MonitorConfig = Depends(get_config),
171
+ _: None = Depends(page_auth_guard),
172
+ ):
173
+ """作業者向け入力フォーム(フェーズ3・F)。
174
+
175
+ テーブルスキーマからタッチ向けフォームを自動生成する。送信は既存の
176
+ CRUD API(POST /api/tables/{table})を使う。
177
+ """
178
+ if table_name not in config.tables:
179
+ raise TableNotFoundError(f"テーブル '{table_name}' は定義されていません")
180
+ service = request.app.state.crud_service
181
+ form = service.form_schema(table_name)
182
+ templates = request.app.state.templates
183
+ return templates.TemplateResponse(
184
+ request,
185
+ "form.html",
186
+ _ctx(config, table_name=table_name, form=form),
187
+ )
188
+
189
+
190
+ @router.get("/kiosk", response_class=HTMLResponse)
191
+ def kiosk(
192
+ request: Request,
193
+ config: MonitorConfig = Depends(get_config),
194
+ _: None = Depends(page_auth_guard),
195
+ ):
196
+ """Andon / 大型表示モード(フェーズ1・B)。
197
+
198
+ 複数ビューを一定間隔で自動ローテーションし、アラートを大きく表示する。
199
+ ラインの大型モニタに常時表示することを想定する。
200
+ """
201
+ templates = request.app.state.templates
202
+ kiosk_cfg = config.kiosk
203
+ kiosk_views = [
204
+ {
205
+ "name": name,
206
+ "title": config.views[name].title or name,
207
+ "chart": (
208
+ config.views[name].chart.model_dump()
209
+ if config.views[name].chart
210
+ else None
211
+ ),
212
+ }
213
+ for name in config.kiosk_views()
214
+ if name in config.views
215
+ ]
216
+ return templates.TemplateResponse(
217
+ request,
218
+ "kiosk.html",
219
+ _ctx(
220
+ config,
221
+ kiosk_json=_escape_json_for_script(
222
+ json.dumps({"views": kiosk_views}, ensure_ascii=False)
223
+ ),
224
+ has_kiosk_charts=any(v["chart"] for v in kiosk_views),
225
+ has_kpis=bool(config.kpis),
226
+ rotate_seconds=kiosk_cfg.rotate_seconds if kiosk_cfg else 15,
227
+ theme=kiosk_cfg.theme if kiosk_cfg else "dark",
228
+ refresh_interval_ms=config.refresh_interval_ms,
229
+ ),
230
+ )
231
+
232
+
233
+ @router.get("/login", response_class=HTMLResponse)
234
+ def login_page(
235
+ request: Request,
236
+ error: int = 0,
237
+ config: MonitorConfig = Depends(get_config),
238
+ ):
239
+ """ログインページ(#11)。protect_reads 有効時に未認証だと誘導される。"""
240
+ templates = request.app.state.templates
241
+ return templates.TemplateResponse(
242
+ request,
243
+ "login.html",
244
+ {
245
+ "app_title": config.app_title,
246
+ "header_text": config.header_text,
247
+ "favicon_path": config.favicon_path,
248
+ "error": bool(error),
249
+ },
250
+ )
251
+
252
+
253
+ @router.get("/table/{view_name}", response_class=HTMLResponse)
254
+ def show_table(
255
+ request: Request,
256
+ view_name: str,
257
+ config: MonitorConfig = Depends(get_config),
258
+ _: None = Depends(page_auth_guard),
259
+ ):
260
+ """1 つのビューを表示するページ。データは JS が API から取得する。"""
261
+ if view_name not in config.views:
262
+ raise ViewNotFoundError(f"ビュー '{view_name}' は定義されていません")
263
+ templates = request.app.state.templates
264
+ vdef = config.views[view_name]
265
+ chart_json = (
266
+ _escape_json_for_script(vdef.chart.model_dump_json()) if vdef.chart else "null"
267
+ )
268
+ return templates.TemplateResponse(
269
+ request,
270
+ "table.html",
271
+ _ctx(
272
+ config,
273
+ view_name=view_name,
274
+ view_title=vdef.title or view_name,
275
+ view_description=vdef.description,
276
+ refresh_interval_ms=config.refresh_interval_ms,
277
+ refresh_mode=config.refresh_mode,
278
+ has_chart=vdef.chart is not None,
279
+ chart_json=chart_json,
280
+ current_view=view_name,
281
+ ),
282
+ )