File size: 12,208 Bytes
5dbdea7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3be8894
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5dbdea7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
import json
import os
import sqlite3
import threading
from datetime import datetime, timezone


def utc_now_iso():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


class MonitorStore:
    """Small SQLite state store for health, alerts and event history."""

    def __init__(self, db_path):
        self.db_path = db_path
        os.makedirs(os.path.dirname(db_path), exist_ok=True)
        self._lock = threading.RLock()
        self._conn = sqlite3.connect(db_path, check_same_thread=False)
        self._conn.row_factory = sqlite3.Row
        self._conn.execute("PRAGMA journal_mode=WAL")
        self._conn.execute("PRAGMA foreign_keys=ON")
        self.init_schema()

    def init_schema(self):
        with self._lock, self._conn:
            self._conn.executescript(
                """
                CREATE TABLE IF NOT EXISTS monitors (
                    id TEXT PRIMARY KEY,
                    type TEXT NOT NULL,
                    name TEXT NOT NULL,
                    enabled INTEGER NOT NULL DEFAULT 1,
                    paused INTEGER NOT NULL DEFAULT 0,
                    schedule_seconds INTEGER,
                    status TEXT NOT NULL DEFAULT 'unknown',
                    last_started_at TEXT,
                    last_success_at TEXT,
                    last_error_at TEXT,
                    last_error TEXT,
                    consecutive_failures INTEGER NOT NULL DEFAULT 0,
                    next_run_at TEXT,
                    last_value_json TEXT,
                    metadata_json TEXT,
                    created_at TEXT NOT NULL,
                    updated_at TEXT NOT NULL
                );

                CREATE TABLE IF NOT EXISTS alerts (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    monitor_id TEXT,
                    category TEXT,
                    title TEXT,
                    direction TEXT,
                    text TEXT NOT NULL,
                    delivered INTEGER NOT NULL DEFAULT 0,
                    error TEXT,
                    created_at TEXT NOT NULL
                );

                CREATE TABLE IF NOT EXISTS events (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    monitor_id TEXT,
                    level TEXT NOT NULL,
                    message TEXT NOT NULL,
                    created_at TEXT NOT NULL
                );

                CREATE TABLE IF NOT EXISTS kv (
                    key TEXT PRIMARY KEY,
                    value TEXT NOT NULL,
                    updated_at TEXT NOT NULL
                );

                CREATE INDEX IF NOT EXISTS idx_alerts_created_at ON alerts(created_at DESC);
                CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at DESC);
                """
            )
            self._ensure_column("monitors", "paused", "INTEGER NOT NULL DEFAULT 0")

    def _ensure_column(self, table, column, definition):
        columns = {
            row["name"]
            for row in self._conn.execute(f"PRAGMA table_info({table})").fetchall()
        }
        if column not in columns:
            self._conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")

    def upsert_monitor(
        self,
        monitor_id,
        monitor_type,
        name,
        enabled=True,
        schedule_seconds=None,
        metadata=None,
    ):
        now = utc_now_iso()
        metadata_json = json.dumps(metadata or {}, ensure_ascii=False)
        with self._lock, self._conn:
            self._conn.execute(
                """
                INSERT INTO monitors (
                    id, type, name, enabled, schedule_seconds, metadata_json,
                    created_at, updated_at
                )
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(id) DO UPDATE SET
                    type=excluded.type,
                    name=excluded.name,
                    enabled=excluded.enabled,
                    schedule_seconds=excluded.schedule_seconds,
                    metadata_json=excluded.metadata_json,
                    updated_at=excluded.updated_at
                """,
                (
                    monitor_id,
                    monitor_type,
                    name,
                    1 if enabled else 0,
                    schedule_seconds,
                    metadata_json,
                    now,
                    now,
                ),
            )

    def apply_paused_ids(self, monitor_ids):
        ids = sorted(set(monitor_ids or []))
        if not ids:
            return
        now = utc_now_iso()
        with self._lock, self._conn:
            self._conn.executemany(
                """
                UPDATE monitors
                SET paused=1, status='paused', next_run_at=NULL, updated_at=?
                WHERE id=?
                """,
                [(now, monitor_id) for monitor_id in ids],
            )

    def get_monitor(self, monitor_id):
        with self._lock:
            row = self._conn.execute(
                "SELECT * FROM monitors WHERE id=?",
                (monitor_id,),
            ).fetchone()
        return dict(row) if row else None

    def set_monitor_paused(self, monitor_id, paused):
        row = self.get_monitor(monitor_id)
        if row is None:
            raise KeyError(f"未知监控项: {monitor_id}")

        now = utc_now_iso()
        status = "paused" if paused else row.get("status") or "unknown"
        if not paused and status == "paused":
            if row.get("last_error_at") and row.get("last_error_at") > (row.get("last_success_at") or ""):
                status = "error"
            elif row.get("last_success_at"):
                status = "ok"
            else:
                status = "unknown"
        with self._lock, self._conn:
            self._conn.execute(
                """
                UPDATE monitors
                SET paused=?, status=?, next_run_at=CASE WHEN ? THEN NULL ELSE next_run_at END,
                    updated_at=?
                WHERE id=?
                """,
                (1 if paused else 0, status, 1 if paused else 0, now, monitor_id),
            )
        self.add_event(monitor_id, "info", "监控项已暂停" if paused else "监控项已恢复")
        return self.get_monitor(monitor_id)

    def is_monitor_paused(self, monitor_id):
        row = self.get_monitor(monitor_id)
        if row is None:
            return False
        return bool(row.get("paused"))

    def mark_paused(self, monitor_id):
        now = utc_now_iso()
        with self._lock, self._conn:
            self._conn.execute(
                """
                UPDATE monitors
                SET status='paused', next_run_at=NULL, updated_at=?
                WHERE id=?
                """,
                (now, monitor_id),
            )

    def mark_started(self, monitor_id):
        now = utc_now_iso()
        with self._lock, self._conn:
            self._conn.execute(
                """
                UPDATE monitors
                SET status='running', last_started_at=?, updated_at=?
                WHERE id=?
                """,
                (now, now, monitor_id),
            )

    def mark_success(self, monitor_id, value=None, next_run_at=None, metadata=None):
        now = utc_now_iso()
        value_json = json.dumps(value, ensure_ascii=False) if value is not None else None
        metadata_json = json.dumps(metadata, ensure_ascii=False) if metadata is not None else None
        with self._lock, self._conn:
            if metadata_json is None:
                self._conn.execute(
                    """
                    UPDATE monitors
                    SET status='ok', last_success_at=?, last_error=NULL,
                        consecutive_failures=0, next_run_at=?,
                        last_value_json=COALESCE(?, last_value_json), updated_at=?
                    WHERE id=?
                    """,
                    (now, next_run_at, value_json, now, monitor_id),
                )
            else:
                self._conn.execute(
                    """
                    UPDATE monitors
                    SET status='ok', last_success_at=?, last_error=NULL,
                        consecutive_failures=0, next_run_at=?,
                        last_value_json=COALESCE(?, last_value_json),
                        metadata_json=?, updated_at=?
                    WHERE id=?
                    """,
                    (now, next_run_at, value_json, metadata_json, now, monitor_id),
                )

    def mark_failure(self, monitor_id, error):
        now = utc_now_iso()
        with self._lock, self._conn:
            row = self._conn.execute(
                "SELECT consecutive_failures FROM monitors WHERE id=?",
                (monitor_id,),
            ).fetchone()
            failures = int(row["consecutive_failures"] if row else 0) + 1
            self._conn.execute(
                """
                UPDATE monitors
                SET status='error', last_error_at=?, last_error=?,
                    consecutive_failures=?, updated_at=?
                WHERE id=?
                """,
                (now, str(error), failures, now, monitor_id),
            )

    def add_alert(
        self,
        monitor_id,
        text,
        category=None,
        title=None,
        direction=None,
        delivered=False,
        error=None,
    ):
        with self._lock, self._conn:
            self._conn.execute(
                """
                INSERT INTO alerts (
                    monitor_id, category, title, direction, text,
                    delivered, error, created_at
                )
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    monitor_id,
                    category,
                    title,
                    direction,
                    text,
                    1 if delivered else 0,
                    error,
                    utc_now_iso(),
                ),
            )

    def add_event(self, monitor_id, level, message):
        with self._lock, self._conn:
            self._conn.execute(
                """
                INSERT INTO events (monitor_id, level, message, created_at)
                VALUES (?, ?, ?, ?)
                """,
                (monitor_id, level, message, utc_now_iso()),
            )

    def set_kv(self, key, value):
        now = utc_now_iso()
        payload = json.dumps(value, ensure_ascii=False)
        with self._lock, self._conn:
            self._conn.execute(
                """
                INSERT INTO kv (key, value, updated_at)
                VALUES (?, ?, ?)
                ON CONFLICT(key) DO UPDATE SET
                    value=excluded.value,
                    updated_at=excluded.updated_at
                """,
                (key, payload, now),
            )

    def get_kv(self, key, default=None):
        with self._lock:
            row = self._conn.execute("SELECT value FROM kv WHERE key=?", (key,)).fetchone()
        if not row:
            return default
        try:
            return json.loads(row["value"])
        except json.JSONDecodeError:
            return default

    def list_monitors(self):
        return self._fetch_all(
            """
            SELECT * FROM monitors
            ORDER BY
              CASE status WHEN 'error' THEN 0 WHEN 'running' THEN 1 WHEN 'unknown' THEN 2 WHEN 'paused' THEN 3 ELSE 4 END,
              id
            """
        )

    def recent_alerts(self, limit=50):
        return self._fetch_all(
            "SELECT * FROM alerts ORDER BY id DESC LIMIT ?",
            (int(limit),),
        )

    def recent_events(self, limit=80):
        return self._fetch_all(
            "SELECT * FROM events ORDER BY id DESC LIMIT ?",
            (int(limit),),
        )

    def _fetch_all(self, sql, params=()):
        with self._lock:
            rows = self._conn.execute(sql, params).fetchall()
        return [dict(row) for row in rows]

    def close(self):
        with self._lock:
            self._conn.close()