mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-10 05:53:05 +09:00
Comments, docstrings and a few strings cited plan numbers ("plan 29
#9", "plan 86 §B2") from a git-ignored folder, so they pointed at
nothing; one reached users in a bottleneck hint. Code and frontend
strings are unchanged apart from those citations (checked by AST and
token comparison). A vhost still carrying the old asset comment reads
as undrifted, and a guard test keeps new citations out.
336 lines
14 KiB
Python
336 lines
14 KiB
Python
"""Dashboard board service.
|
|
|
|
Owns the shipped default boards and the per-user CRUD around them. A board is a
|
|
dashboard tab; a widget instance is ``{i, type, x, y, w, h, cfg}`` placed on a
|
|
12-column grid.
|
|
|
|
Grid rules the defaults below obey (the frontend's ``grid/layout.js`` enforces
|
|
the same ones at runtime):
|
|
|
|
* 12 columns. ``x + w <= 12`` for every widget.
|
|
* No two widgets on a board overlap.
|
|
* Gravity-up: no widget can float higher without colliding, so there are no
|
|
floating rows or holes above a widget.
|
|
* Every widget respects its type's minimum size from the widget registry.
|
|
|
|
``cfg`` is free-form per widget type and the backend does not interpret it —
|
|
it is stored and handed back verbatim. The one exception is ``_repair_board``,
|
|
which rewrites config keys that no renderer reads any more (see below); it never
|
|
invents config the user did not set. The shapes used here mirror the widget table in
|
|
the plan contract:
|
|
|
|
* ``stat`` ``{metric, agg, resource, thresholds}``
|
|
* ``timeseries`` ``{series: [{resource, metric}, ...], legend, fill}``
|
|
* ``gauge`` ``{metric, resource, thresholds}``
|
|
* ``topn`` ``{metric, limit}``
|
|
* ``table`` ``{source: 'apps'|'servers'|'containers'|'deploys', limit}``
|
|
* ``logs`` ``{source, path, containerId, level, lines}``
|
|
* ``deploys`` / ``alerts`` / ``status`` / ``feed`` ``{limit, …}``
|
|
* ``actions`` ``{items: [...]}`` (keys the renderer can route)
|
|
* ``specs`` ``{resource}``
|
|
|
|
``resource: '$server'`` is the server variable the dashboard topbar binds, so a
|
|
default board follows whichever server the operator has selected.
|
|
|
|
Metrics are limited to the ones the panel actually records
|
|
(``MetricsHistory``): cpu, ram, disk and load.
|
|
"""
|
|
|
|
import copy
|
|
|
|
from app import db
|
|
from app.models.dashboard import DashboardBoard
|
|
|
|
# Grid width. Mirrors GRID_COLS in the frontend's grid/layout.js.
|
|
GRID_COLS = 12
|
|
|
|
# Hard ceiling on widgets per board, enforced by the API layer. Generous for a
|
|
# real dashboard, small enough that a runaway client can't store a megabyte of
|
|
# JSON per board.
|
|
MAX_WIDGETS_PER_BOARD = 100
|
|
|
|
|
|
DEFAULT_BOARDS = [
|
|
{
|
|
'slug': 'overview',
|
|
'name': 'Overview',
|
|
'icon': 'grid',
|
|
'position': 0,
|
|
'widgets': [
|
|
# Row 0-1: four stats across the top.
|
|
{'i': 'w1', 'type': 'stat', 'x': 0, 'y': 0, 'w': 3, 'h': 2,
|
|
'cfg': {'title': 'CPU usage', 'metric': 'cpu', 'agg': 'last', 'resource': '$server',
|
|
'thresholds': [70, 90]}},
|
|
{'i': 'w2', 'type': 'stat', 'x': 3, 'y': 0, 'w': 3, 'h': 2,
|
|
'cfg': {'title': 'Memory', 'metric': 'ram', 'agg': 'last', 'resource': '$server',
|
|
'thresholds': [80, 92]}},
|
|
{'i': 'w3', 'type': 'stat', 'x': 6, 'y': 0, 'w': 3, 'h': 2,
|
|
'cfg': {'title': 'Disk', 'metric': 'disk', 'agg': 'last', 'resource': '$server',
|
|
'thresholds': [75, 90]}},
|
|
{'i': 'w4', 'type': 'stat', 'x': 9, 'y': 0, 'w': 3, 'h': 2,
|
|
'cfg': {'title': 'Load average', 'metric': 'load', 'agg': 'last', 'resource': '$server',
|
|
'thresholds': None}},
|
|
# Rows 2-5: the wide chart, with quick actions + host details stacked
|
|
# in the right-hand column.
|
|
{'i': 'w5', 'type': 'timeseries', 'x': 0, 'y': 2, 'w': 8, 'h': 4,
|
|
'cfg': {'title': 'Real-time performance', 'series': [{'resource': '$server', 'metric': 'cpu'}, {'resource': '$server', 'metric': 'ram'}],
|
|
'legend': True, 'fill': True}},
|
|
{'i': 'w6', 'type': 'actions', 'x': 8, 'y': 2, 'w': 4, 'h': 2,
|
|
'cfg': {'title': 'Quick actions', 'items': ['services', 'docker', 'terminal', 'backups']}},
|
|
{'i': 'w7', 'type': 'specs', 'x': 8, 'y': 4, 'w': 4, 'h': 2,
|
|
'cfg': {'title': 'Hardware specs', 'resource': '$server'}},
|
|
# Rows 6-9: inventory + what happened.
|
|
{'i': 'w8', 'type': 'table', 'x': 0, 'y': 6, 'w': 8, 'h': 4,
|
|
'cfg': {'title': 'Applications', 'source': 'apps', 'limit': 8}},
|
|
{'i': 'w9', 'type': 'feed', 'x': 8, 'y': 6, 'w': 4, 'h': 4,
|
|
'cfg': {'title': 'Recent activity', 'limit': 10}},
|
|
],
|
|
},
|
|
{
|
|
'slug': 'infra',
|
|
'name': 'Infrastructure',
|
|
'icon': 'server',
|
|
'position': 1,
|
|
'widgets': [
|
|
# Rows 0-3: fleet-wide chart + the ranked hot list.
|
|
{'i': 'w1', 'type': 'timeseries', 'x': 0, 'y': 0, 'w': 8, 'h': 4,
|
|
'cfg': {'title': 'Fleet performance', 'series': [{'resource': '$server', 'metric': 'cpu'}, {'resource': '$server', 'metric': 'ram'}, {'resource': '$server', 'metric': 'disk'}],
|
|
'legend': True, 'fill': True}},
|
|
{'i': 'w2', 'type': 'topn', 'x': 8, 'y': 0, 'w': 4, 'h': 4,
|
|
'cfg': {'title': 'Busiest servers', 'metric': 'cpu', 'limit': 5}},
|
|
# Rows 4-6: two dials and the up/down matrix.
|
|
{'i': 'w3', 'type': 'gauge', 'x': 0, 'y': 4, 'w': 3, 'h': 3,
|
|
'cfg': {'title': 'CPU', 'metric': 'cpu', 'resource': '$server',
|
|
'thresholds': [70, 90]}},
|
|
{'i': 'w4', 'type': 'gauge', 'x': 3, 'y': 4, 'w': 3, 'h': 3,
|
|
'cfg': {'title': 'Memory', 'metric': 'ram', 'resource': '$server',
|
|
'thresholds': [80, 92]}},
|
|
{'i': 'w5', 'type': 'status', 'x': 6, 'y': 4, 'w': 6, 'h': 3,
|
|
'cfg': {'title': 'Endpoint status', 'source': 'servers', 'limit': 12}},
|
|
# Rows 7-10: containers + anything currently shouting.
|
|
{'i': 'w6', 'type': 'table', 'x': 0, 'y': 7, 'w': 8, 'h': 4,
|
|
'cfg': {'title': 'Containers', 'source': 'containers', 'limit': 8}},
|
|
{'i': 'w7', 'type': 'alerts', 'x': 8, 'y': 7, 'w': 4, 'h': 4,
|
|
'cfg': {'title': 'Open alerts', 'limit': 8, 'severity': 'all'}},
|
|
],
|
|
},
|
|
{
|
|
'slug': 'apps',
|
|
'name': 'Apps',
|
|
'icon': 'rocket',
|
|
'position': 2,
|
|
'widgets': [
|
|
# Rows 0-1: three wider stats, averaged over the selected range.
|
|
{'i': 'w1', 'type': 'stat', 'x': 0, 'y': 0, 'w': 4, 'h': 2,
|
|
'cfg': {'title': 'CPU (average)', 'metric': 'cpu', 'agg': 'avg', 'resource': '$server',
|
|
'thresholds': [70, 90]}},
|
|
{'i': 'w2', 'type': 'stat', 'x': 4, 'y': 0, 'w': 4, 'h': 2,
|
|
'cfg': {'title': 'Memory (average)', 'metric': 'ram', 'agg': 'avg', 'resource': '$server',
|
|
'thresholds': [80, 92]}},
|
|
{'i': 'w3', 'type': 'stat', 'x': 8, 'y': 0, 'w': 4, 'h': 2,
|
|
'cfg': {'title': 'Load average', 'metric': 'load', 'agg': 'last', 'resource': '$server',
|
|
'thresholds': None}},
|
|
# Rows 2-5: load over time next to the deploy stream.
|
|
{'i': 'w4', 'type': 'timeseries', 'x': 0, 'y': 2, 'w': 8, 'h': 4,
|
|
'cfg': {'title': 'Performance over time', 'series': [{'resource': '$server', 'metric': 'cpu'}, {'resource': '$server', 'metric': 'ram'}],
|
|
'legend': True, 'fill': True}},
|
|
{'i': 'w5', 'type': 'deploys', 'x': 8, 'y': 2, 'w': 4, 'h': 4,
|
|
'cfg': {'title': 'Deploy activity', 'limit': 8}},
|
|
# Rows 6-9: full-width tail.
|
|
{'i': 'w6', 'type': 'logs', 'x': 0, 'y': 6, 'w': 12, 'h': 4,
|
|
'cfg': {'title': 'Live logs', 'source': 'file', 'path': '', 'containerId': '',
|
|
'level': 'all', 'lines': 60}},
|
|
],
|
|
},
|
|
]
|
|
|
|
|
|
def get_default_board(slug):
|
|
"""A deep copy of one shipped default, or None when the slug is unknown."""
|
|
for spec in DEFAULT_BOARDS:
|
|
if spec['slug'] == slug:
|
|
return copy.deepcopy(spec)
|
|
return None
|
|
|
|
|
|
def seed_defaults(user_id):
|
|
"""Create the shipped default boards for a user. Returns the new rows."""
|
|
created = []
|
|
for spec in DEFAULT_BOARDS:
|
|
board = DashboardBoard(
|
|
user_id=user_id,
|
|
slug=spec['slug'],
|
|
name=spec['name'],
|
|
icon=spec['icon'],
|
|
position=spec['position'],
|
|
widgets=copy.deepcopy(spec['widgets']),
|
|
)
|
|
db.session.add(board)
|
|
created.append(board)
|
|
db.session.commit()
|
|
return created
|
|
|
|
|
|
# Widget configs that early boards were seeded with, mapped to the keys the
|
|
# renderers actually read. Fixing DEFAULT_BOARDS only helps users who have never
|
|
# opened the dashboard — everyone else already has rows in the database, and
|
|
# seeding never runs again for them. So repair on read.
|
|
#
|
|
# Each entry: (old key, new key, converter). A converter of None copies through.
|
|
_CFG_RENAMES = {
|
|
# `metrics: ['cpu','ram']` -> `series: [{resource, metric}, ...]`
|
|
'timeseries': [('metrics', 'series',
|
|
lambda v, cfg: [{'resource': cfg.get('resource', '$server'),
|
|
'metric': m} for m in v if isinstance(m, str)])],
|
|
# `actions: [...]` -> `items: [...]`
|
|
'actions': [('actions', 'items', None)],
|
|
}
|
|
|
|
# Shortcut keys the quick-actions widget can resolve (ACTIONS in renderers.jsx).
|
|
# Early boards seeded 'new-app'/'deploy'/'backup', none of which route anywhere.
|
|
_QUICK_ACTIONS = {
|
|
'servers', 'services', 'docker', 'terminal', 'deploys', 'databases',
|
|
'backups', 'monitoring', 'domains', 'files', 'security', 'jobs',
|
|
}
|
|
_QUICK_ACTION_ALIASES = {'new-app': 'services', 'deploy': 'deploys', 'backup': 'backups'}
|
|
_DEFAULT_QUICK_ACTIONS = ['services', 'docker', 'terminal', 'backups']
|
|
|
|
|
|
|
|
|
|
|
|
def _repair_widget_cfg(widget):
|
|
"""Bring one stored widget's cfg up to the current renderer contract.
|
|
|
|
Returns True when something changed. Deliberately conservative: it renames
|
|
known-dead keys and drops unroutable shortcuts, and never touches config the
|
|
renderers understand.
|
|
"""
|
|
if not isinstance(widget, dict):
|
|
return False
|
|
cfg = widget.get('cfg')
|
|
if not isinstance(cfg, dict):
|
|
return False
|
|
|
|
changed = False
|
|
for old, new, convert in _CFG_RENAMES.get(widget.get('type'), []):
|
|
if old in cfg and new not in cfg:
|
|
value = cfg.pop(old)
|
|
cfg[new] = convert(value, cfg) if convert else value
|
|
changed = True
|
|
|
|
if widget.get('type') == 'actions':
|
|
items = cfg.get('items')
|
|
mapped = [_QUICK_ACTION_ALIASES.get(i, i)
|
|
for i in (items or []) if isinstance(i, str)]
|
|
kept = [i for i in dict.fromkeys(mapped) if i in _QUICK_ACTIONS]
|
|
# An empty shortcut list renders "No actions selected", which is a
|
|
# useless thing to greet someone with. If nothing survived (or nothing
|
|
# was ever set), fall back to the shipped four.
|
|
if not kept:
|
|
kept = _DEFAULT_QUICK_ACTIONS[:]
|
|
if kept != items:
|
|
cfg['items'] = kept
|
|
changed = True
|
|
|
|
return changed
|
|
|
|
|
|
def _repair_board(board):
|
|
"""Bring one board's stored widgets up to the current renderer contract.
|
|
|
|
Works on a deep copy on purpose. A JSON column mutated in place looks
|
|
unchanged to SQLAlchemy, so no UPDATE is emitted — and the commit then
|
|
expires the instance, which reloads the ORIGINAL rows and silently undoes
|
|
the repair. Copying first is what makes the change both detected and kept.
|
|
"""
|
|
widgets = copy.deepcopy(board.widgets or [])
|
|
# Only dead config is rewritten. A missing `title` is NOT repaired here —
|
|
# writing a title into config the user never set would break the promise
|
|
# that a widget's cfg round-trips verbatim. The frame derives a display
|
|
# title instead (deriveWidgetTitle in the frontend registry).
|
|
changed = any([_repair_widget_cfg(w) for w in widgets])
|
|
|
|
if not changed:
|
|
return False
|
|
board.widgets = widgets
|
|
return True
|
|
|
|
|
|
def get_boards_for_user(user_id):
|
|
"""Every board owned by this user, seeding the shipped defaults on the
|
|
first read for a user who has none, and repairing configs that predate the
|
|
current renderer contract."""
|
|
boards = (DashboardBoard.query
|
|
.filter_by(user_id=user_id)
|
|
.order_by(DashboardBoard.position, DashboardBoard.id)
|
|
.all())
|
|
if boards:
|
|
# Not `any(...)`: it short-circuits, so only the first repairable board
|
|
# would ever be fixed.
|
|
if [_repair_board(b) for b in boards].count(True):
|
|
db.session.commit()
|
|
return boards
|
|
seed_defaults(user_id)
|
|
return (DashboardBoard.query
|
|
.filter_by(user_id=user_id)
|
|
.order_by(DashboardBoard.position, DashboardBoard.id)
|
|
.all())
|
|
|
|
|
|
def get_board_for_user(user_id, board_id):
|
|
"""One board, scoped to its owner. None means "not yours / not there" — the
|
|
caller must not distinguish the two."""
|
|
return DashboardBoard.query.filter_by(id=board_id, user_id=user_id).first()
|
|
|
|
|
|
def create_board(user_id, name, icon=None, widgets=None):
|
|
"""Add a user-created board after the existing ones. ``slug`` stays NULL:
|
|
a board the user invented has no shipped default to reset to."""
|
|
last = (db.session.query(db.func.max(DashboardBoard.position))
|
|
.filter(DashboardBoard.user_id == user_id).scalar())
|
|
board = DashboardBoard(
|
|
user_id=user_id,
|
|
slug=None,
|
|
name=name,
|
|
icon=icon or 'grid',
|
|
position=(last + 1) if last is not None else 0,
|
|
widgets=list(widgets or []),
|
|
)
|
|
db.session.add(board)
|
|
db.session.commit()
|
|
return board
|
|
|
|
|
|
def update_board(board, name=None, icon=None, position=None, widgets=None):
|
|
"""Patch the fields that were actually supplied."""
|
|
if name is not None:
|
|
board.name = name
|
|
if icon is not None:
|
|
board.icon = icon
|
|
if position is not None:
|
|
board.position = position
|
|
if widgets is not None:
|
|
board.widgets = list(widgets)
|
|
db.session.commit()
|
|
return board
|
|
|
|
|
|
def delete_board(board):
|
|
db.session.delete(board)
|
|
db.session.commit()
|
|
return True
|
|
|
|
|
|
def reset_board(board):
|
|
"""Restore a board to its shipped default. Returns None when the board was
|
|
created by the user and therefore has no default to go back to."""
|
|
spec = get_default_board(board.slug) if board.slug else None
|
|
if not spec:
|
|
return None
|
|
board.name = spec['name']
|
|
board.icon = spec['icon']
|
|
board.widgets = copy.deepcopy(spec['widgets'])
|
|
db.session.commit()
|
|
return board
|