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.
- {monitor_app-2.1.0 → monitor_app-2.3.0}/PKG-INFO +27 -4
- {monitor_app-2.1.0 → monitor_app-2.3.0}/README.md +26 -3
- monitor_app-2.3.0/monitor_app/api/deps.py +140 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/errors.py +7 -1
- monitor_app-2.3.0/monitor_app/api/routers/auth.py +51 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/crud.py +7 -5
- monitor_app-2.3.0/monitor_app/api/routers/pages.py +282 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/views.py +77 -24
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/schemas.py +1 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/cli.py +52 -2
- monitor_app-2.3.0/monitor_app/db/ingest_state.py +49 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/db/registry.py +13 -1
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/db/repository.py +10 -2
- monitor_app-2.3.0/monitor_app/integrations/__init__.py +53 -0
- monitor_app-2.3.0/monitor_app/integrations/netdiag.py +166 -0
- monitor_app-2.3.0/monitor_app/integrations/network_checker.py +192 -0
- monitor_app-2.3.0/monitor_app/integrations/procdiag.py +281 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/main.py +58 -6
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/config.py +40 -3
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/env.example +0 -1
- monitor_app-2.3.0/monitor_app/services/alert_scheduler.py +73 -0
- monitor_app-2.3.0/monitor_app/services/alert_service.py +203 -0
- monitor_app-2.3.0/monitor_app/services/connectors.py +439 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/crud_service.py +38 -8
- monitor_app-2.3.0/monitor_app/services/importer.py +182 -0
- monitor_app-2.3.0/monitor_app/services/ingest_watcher.py +138 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/kpi_service.py +22 -7
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/notifiers/__init__.py +0 -3
- monitor_app-2.3.0/monitor_app/services/view_cache.py +39 -0
- monitor_app-2.3.0/monitor_app/services/view_service.py +107 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/settings/declarative.py +105 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/settings/runtime.py +8 -1
- monitor_app-2.3.0/monitor_app/web/static/css/app.css +571 -0
- monitor_app-2.3.0/monitor_app/web/static/css/dashboard.css +147 -0
- monitor_app-2.3.0/monitor_app/web/static/css/kiosk.css +189 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/js/alerts-ui.js +23 -30
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/js/app.js +50 -63
- monitor_app-2.3.0/monitor_app/web/static/js/chart-view.js +195 -0
- monitor_app-2.3.0/monitor_app/web/static/js/dashboard.js +193 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/js/form.js +7 -0
- monitor_app-2.3.0/monitor_app/web/static/js/kiosk.js +220 -0
- monitor_app-2.3.0/monitor_app/web/static/js/kpis.js +104 -0
- monitor_app-2.3.0/monitor_app/web/static/js/table-render.js +87 -0
- monitor_app-2.3.0/monitor_app/web/static/js/theme.js +73 -0
- monitor_app-2.3.0/monitor_app/web/templates/base.html +110 -0
- monitor_app-2.3.0/monitor_app/web/templates/index.html +116 -0
- monitor_app-2.3.0/monitor_app/web/templates/kiosk.html +75 -0
- monitor_app-2.3.0/monitor_app/web/templates/login.html +49 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/templates/table.html +26 -6
- {monitor_app-2.1.0 → monitor_app-2.3.0}/pyproject.toml +25 -1
- monitor_app-2.1.0/monitor_app/api/deps.py +0 -88
- monitor_app-2.1.0/monitor_app/api/routers/pages.py +0 -111
- monitor_app-2.1.0/monitor_app/services/alert_service.py +0 -130
- monitor_app-2.1.0/monitor_app/services/importer.py +0 -114
- monitor_app-2.1.0/monitor_app/services/ingest_watcher.py +0 -74
- monitor_app-2.1.0/monitor_app/services/notifiers/line.py +0 -22
- monitor_app-2.1.0/monitor_app/services/view_service.py +0 -54
- monitor_app-2.1.0/monitor_app/web/static/css/app.css +0 -278
- monitor_app-2.1.0/monitor_app/web/static/css/kiosk.css +0 -100
- monitor_app-2.1.0/monitor_app/web/static/js/chart-view.js +0 -95
- monitor_app-2.1.0/monitor_app/web/static/js/kiosk.js +0 -131
- monitor_app-2.1.0/monitor_app/web/static/js/kpis.js +0 -57
- monitor_app-2.1.0/monitor_app/web/templates/base.html +0 -44
- monitor_app-2.1.0/monitor_app/web/templates/index.html +0 -55
- monitor_app-2.1.0/monitor_app/web/templates/kiosk.html +0 -53
- {monitor_app-2.1.0 → monitor_app-2.3.0}/LICENSE +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/__init__.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/__init__.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/__init__.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/alerts.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/audit.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/ingest.py +1 -1
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/api/routers/meta.py +1 -1
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/db/__init__.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/db/engine.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/exceptions.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/logging_conf.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/README.md +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/csv/orders.csv +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/csv/products.csv +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/scaffold/csv/users.csv +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/__init__.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/audit_service.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/coercion.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/notifiers/base.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/notifiers/email.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/services/notifiers/webhook.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/settings/__init__.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/settings/loader.py +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/css/bootstrap.min.css +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/img/background.webp +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/img/favicon.ico +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/img/logo.webp +0 -0
- {monitor_app-2.1.0 → monitor_app-2.3.0}/monitor_app/web/static/js/chart.umd.min.js +0 -0
- {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.
|
|
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 +
|
|
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
|
|
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 +
|
|
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
|
|
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
|
+
)
|