mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 08:07:37 +09:00
The board carried two headers: a host identity strip (hostname, live dot, IP, kernel, uptime, clock) and, under it, the board tabs and controls. Two rows of chrome before any actual dashboard, and the strip was fixed — you could not move it, size it, or point it at a different machine. It is one row now: board tabs left, then the server picker, range, refresh, TV and Edit. The server switcher moved into that row as a labelled `server` pill, and still collapses to a plain readout when there is only one host. Nothing was lost, it just became placeable. IP, kernel and uptime are the Host details widget — which can be resized and pointed at any server, unlike the strip. And the time is a new Clock widget: add it if you want it, put it on a second timezone if that is what you need, leave it off if you do not. It ticks locally rather than polling, because re-fetching every second to print a number the browser already knows would be silly. Also fixes a regression from the previous commit: making the spark row flexible let it compete with the value for height, and centring then pushed the number up out of the tile. The reading is the point of a stat widget, so it is pinned first and the chart takes what is left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
467 lines
21 KiB
Python
467 lines
21 KiB
Python
"""Dashboard board persistence tests (plan 62).
|
|
|
|
Proves the backend contract behind the widget grid: shipped defaults are seeded
|
|
once per user, boards are CRUD-able, a default board can be restored, one user
|
|
can never see or touch another's board (404, never 403), and a malformed widget
|
|
payload is refused before it reaches the database.
|
|
"""
|
|
import pytest
|
|
|
|
from app.services.dashboard_service import DEFAULT_BOARDS, GRID_COLS
|
|
|
|
|
|
def _headers_for(app, username):
|
|
"""A second (non-admin) user's auth headers — the neighbour whose boards
|
|
must stay invisible."""
|
|
from app import db
|
|
from app.models import User
|
|
from flask_jwt_extended import create_access_token
|
|
from werkzeug.security import generate_password_hash
|
|
|
|
with app.app_context():
|
|
user = User(
|
|
email=f'{username}@test.local', username=username,
|
|
password_hash=generate_password_hash('x'),
|
|
role=User.ROLE_DEVELOPER, is_active=True,
|
|
)
|
|
db.session.add(user)
|
|
db.session.commit()
|
|
return {'Authorization': f'Bearer {create_access_token(identity=user.id)}'}
|
|
|
|
|
|
@pytest.fixture
|
|
def other_headers(app):
|
|
return _headers_for(app, 'boardneighbour')
|
|
|
|
|
|
def _widget(i='w1', wtype='stat', x=0, y=0, w=3, h=2, cfg=None):
|
|
return {'i': i, 'type': wtype, 'x': x, 'y': y, 'w': w, 'h': h, 'cfg': cfg or {}}
|
|
|
|
|
|
# ---------------------------------------------------------------- auth gate
|
|
|
|
def test_boards_require_auth(client):
|
|
assert client.get('/api/v1/dashboards').status_code == 401
|
|
|
|
|
|
# ------------------------------------------------------------- seed defaults
|
|
|
|
def test_first_get_seeds_shipped_defaults(client, auth_headers):
|
|
resp = client.get('/api/v1/dashboards', headers=auth_headers)
|
|
assert resp.status_code == 200
|
|
boards = resp.get_json()['boards']
|
|
|
|
assert [b['slug'] for b in boards] == ['overview', 'infra', 'apps']
|
|
assert [b['position'] for b in boards] == [0, 1, 2]
|
|
assert [b['icon'] for b in boards] == ['grid', 'server', 'rocket']
|
|
assert all(b['widgets'] for b in boards)
|
|
|
|
overview = boards[0]
|
|
types = [w['type'] for w in overview['widgets']]
|
|
assert types.count('stat') == 4
|
|
assert {'timeseries', 'actions', 'specs', 'table', 'feed'}.issubset(set(types))
|
|
table = next(w for w in overview['widgets'] if w['type'] == 'table')
|
|
assert table['cfg']['source'] == 'apps'
|
|
|
|
|
|
def test_defaults_are_seeded_only_once(client, auth_headers):
|
|
first = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
second = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
assert [b['id'] for b in first] == [b['id'] for b in second]
|
|
assert len(second) == len(DEFAULT_BOARDS)
|
|
|
|
|
|
def test_default_layouts_obey_the_grid_rules():
|
|
"""12 columns, no overlaps, gravity-up — the same invariants the frontend
|
|
grid enforces at runtime."""
|
|
for spec in DEFAULT_BOARDS:
|
|
widgets = spec['widgets']
|
|
assert len({w['i'] for w in widgets}) == len(widgets), spec['slug']
|
|
|
|
cells = {}
|
|
for w in widgets:
|
|
assert w['x'] >= 0 and w['y'] >= 0, (spec['slug'], w['i'])
|
|
assert w['w'] >= 1 and w['h'] >= 1, (spec['slug'], w['i'])
|
|
assert w['x'] + w['w'] <= GRID_COLS, (spec['slug'], w['i'])
|
|
for col in range(w['x'], w['x'] + w['w']):
|
|
for row in range(w['y'], w['y'] + w['h']):
|
|
assert (col, row) not in cells, (
|
|
f"{spec['slug']}: {w['i']} overlaps {cells.get((col, row))}")
|
|
cells[(col, row)] = w['i']
|
|
|
|
# Gravity-up: nothing may float. A widget below row 0 must be blocked
|
|
# by something directly above it.
|
|
for w in widgets:
|
|
if w['y'] == 0:
|
|
continue
|
|
blocked = any((col, w['y'] - 1) in cells
|
|
for col in range(w['x'], w['x'] + w['w']))
|
|
assert blocked, f"{spec['slug']}: {w['i']} floats above a hole"
|
|
|
|
|
|
# --------------------------------------------------------------------- CRUD
|
|
|
|
def test_create_board(client, auth_headers):
|
|
client.get('/api/v1/dashboards', headers=auth_headers) # seed the defaults
|
|
resp = client.post('/api/v1/dashboards', headers=auth_headers, json={
|
|
'name': 'Night shift', 'icon': 'zap',
|
|
'widgets': [_widget(wtype='gauge', w=3, h=3)],
|
|
})
|
|
assert resp.status_code == 201, resp.get_json()
|
|
board = resp.get_json()
|
|
assert board['name'] == 'Night shift'
|
|
assert board['icon'] == 'zap'
|
|
assert board['slug'] is None
|
|
assert len(board['widgets']) == 1
|
|
|
|
listing = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
assert len(listing) == len(DEFAULT_BOARDS) + 1
|
|
# Created boards land after the seeded defaults.
|
|
assert listing[-1]['id'] == board['id']
|
|
|
|
|
|
def test_create_board_requires_a_name(client, auth_headers):
|
|
resp = client.post('/api/v1/dashboards', headers=auth_headers, json={'icon': 'zap'})
|
|
assert resp.status_code == 400
|
|
assert 'error' in resp.get_json()
|
|
|
|
|
|
def test_update_board_stores_layout(client, auth_headers):
|
|
boards = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
board_id = boards[0]['id']
|
|
|
|
layout = [_widget('a', 'stat', 0, 0, 4, 2), _widget('b', 'logs', 4, 0, 8, 4)]
|
|
resp = client.put(f'/api/v1/dashboards/{board_id}', headers=auth_headers,
|
|
json={'name': 'Renamed', 'icon': 'cpu', 'widgets': layout})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
updated = resp.get_json()
|
|
assert updated['name'] == 'Renamed'
|
|
assert updated['icon'] == 'cpu'
|
|
assert [w['i'] for w in updated['widgets']] == ['a', 'b']
|
|
|
|
reread = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
assert reread[0]['widgets'][1]['type'] == 'logs'
|
|
|
|
|
|
def test_update_board_accepts_partial_payload(client, auth_headers):
|
|
boards = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
board = boards[1]
|
|
resp = client.put(f"/api/v1/dashboards/{board['id']}", headers=auth_headers,
|
|
json={'position': 9})
|
|
assert resp.status_code == 200
|
|
updated = resp.get_json()
|
|
assert updated['position'] == 9
|
|
assert updated['name'] == board['name']
|
|
assert updated['widgets'] == board['widgets']
|
|
|
|
|
|
def test_delete_board(client, auth_headers):
|
|
boards = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
victim = boards[2]['id']
|
|
|
|
assert client.delete(f'/api/v1/dashboards/{victim}',
|
|
headers=auth_headers).status_code == 200
|
|
|
|
remaining = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
assert victim not in [b['id'] for b in remaining]
|
|
# The survivors are NOT re-seeded — seeding only happens for a user with no
|
|
# boards at all.
|
|
assert len(remaining) == len(DEFAULT_BOARDS) - 1
|
|
|
|
|
|
def test_unknown_board_is_404(client, auth_headers):
|
|
assert client.put('/api/v1/dashboards/99999', headers=auth_headers,
|
|
json={'name': 'x'}).status_code == 404
|
|
assert client.delete('/api/v1/dashboards/99999',
|
|
headers=auth_headers).status_code == 404
|
|
assert client.post('/api/v1/dashboards/99999/reset',
|
|
headers=auth_headers).status_code == 404
|
|
|
|
|
|
# -------------------------------------------------------------------- reset
|
|
|
|
def test_reset_restores_the_shipped_default(client, auth_headers):
|
|
boards = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
overview = boards[0]
|
|
original = overview['widgets']
|
|
|
|
client.put(f"/api/v1/dashboards/{overview['id']}", headers=auth_headers,
|
|
json={'name': 'Wrecked', 'widgets': [_widget('solo', 'note')]})
|
|
|
|
resp = client.post(f"/api/v1/dashboards/{overview['id']}/reset", headers=auth_headers)
|
|
assert resp.status_code == 200, resp.get_json()
|
|
restored = resp.get_json()
|
|
assert restored['name'] == 'Overview'
|
|
assert restored['widgets'] == original
|
|
|
|
|
|
def test_reset_refuses_a_user_created_board(client, auth_headers):
|
|
created = client.post('/api/v1/dashboards', headers=auth_headers,
|
|
json={'name': 'Mine'}).get_json()
|
|
resp = client.post(f"/api/v1/dashboards/{created['id']}/reset", headers=auth_headers)
|
|
assert resp.status_code == 400
|
|
assert 'error' in resp.get_json()
|
|
|
|
|
|
# ----------------------------------------------------------- user isolation
|
|
|
|
def test_boards_are_scoped_per_user(client, auth_headers, other_headers):
|
|
mine = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
theirs = client.get('/api/v1/dashboards', headers=other_headers).get_json()['boards']
|
|
|
|
# Each user got their own seeded copy — same slugs, different rows.
|
|
assert [b['slug'] for b in mine] == [b['slug'] for b in theirs]
|
|
assert not set(b['id'] for b in mine) & set(b['id'] for b in theirs)
|
|
|
|
|
|
def test_a_foreign_board_is_404_not_403(client, auth_headers, other_headers):
|
|
mine = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
target = mine[0]['id']
|
|
|
|
# Seed the neighbour so their own boards exist and the id is simply not theirs.
|
|
client.get('/api/v1/dashboards', headers=other_headers)
|
|
|
|
assert client.put(f'/api/v1/dashboards/{target}', headers=other_headers,
|
|
json={'name': 'stolen'}).status_code == 404
|
|
assert client.post(f'/api/v1/dashboards/{target}/reset',
|
|
headers=other_headers).status_code == 404
|
|
assert client.delete(f'/api/v1/dashboards/{target}',
|
|
headers=other_headers).status_code == 404
|
|
|
|
# The owner's board is untouched.
|
|
still = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
assert still[0]['id'] == target
|
|
assert still[0]['name'] == 'Overview'
|
|
|
|
|
|
# ------------------------------------------------------- payload validation
|
|
|
|
@pytest.mark.parametrize('payload,reason', [
|
|
('not-a-list', 'widgets must be a list'),
|
|
({'i': 'w1'}, 'a dict is not a list'),
|
|
([{'type': 'stat', 'x': 0, 'y': 0, 'w': 3, 'h': 2}], 'missing i'),
|
|
([{'i': 5, 'type': 'stat', 'x': 0, 'y': 0, 'w': 3, 'h': 2}], 'non-string i'),
|
|
([{'i': 'w1', 'x': 0, 'y': 0, 'w': 3, 'h': 2}], 'missing type'),
|
|
([{'i': 'w1', 'type': 'stat', 'x': '0', 'y': 0, 'w': 3, 'h': 2}], 'string x'),
|
|
([{'i': 'w1', 'type': 'stat', 'x': 0, 'y': 0, 'w': 3.5, 'h': 2}], 'float w'),
|
|
([{'i': 'w1', 'type': 'stat', 'x': True, 'y': 0, 'w': 3, 'h': 2}], 'bool x'),
|
|
([{'i': 'w1', 'type': 'stat', 'x': -1, 'y': 0, 'w': 3, 'h': 2}], 'negative x'),
|
|
([{'i': 'w1', 'type': 'stat', 'x': 0, 'y': 0, 'w': 0, 'h': 2}], 'zero width'),
|
|
([{'i': 'w1', 'type': 'stat', 'x': 10, 'y': 0, 'w': 4, 'h': 2}], 'overflows 12 cols'),
|
|
([{'i': 'w1', 'type': 'stat', 'x': 0, 'y': 0, 'w': 3, 'h': 2, 'cfg': 'nope'}], 'cfg not an object'),
|
|
(['just-a-string'], 'entry is not an object'),
|
|
])
|
|
def test_bad_widget_payloads_are_rejected(client, auth_headers, payload, reason):
|
|
resp = client.post('/api/v1/dashboards', headers=auth_headers,
|
|
json={'name': 'Bad', 'widgets': payload})
|
|
assert resp.status_code == 400, f'{reason} should be rejected'
|
|
assert 'error' in resp.get_json()
|
|
|
|
|
|
def test_duplicate_widget_ids_are_rejected(client, auth_headers):
|
|
resp = client.post('/api/v1/dashboards', headers=auth_headers, json={
|
|
'name': 'Dupes', 'widgets': [_widget('w1'), _widget('w1', x=3)],
|
|
})
|
|
assert resp.status_code == 400
|
|
assert 'duplicate' in resp.get_json()['error'].lower()
|
|
|
|
|
|
def test_oversized_widget_payload_is_rejected(client, auth_headers):
|
|
huge = [_widget(f'w{n}', x=(n % 4) * 3, y=n) for n in range(101)]
|
|
resp = client.post('/api/v1/dashboards', headers=auth_headers,
|
|
json={'name': 'Huge', 'widgets': huge})
|
|
assert resp.status_code == 400
|
|
assert '100' in resp.get_json()['error']
|
|
|
|
|
|
def test_update_rejects_a_bad_layout_without_touching_the_board(client, auth_headers):
|
|
boards = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
board = boards[0]
|
|
|
|
resp = client.put(f"/api/v1/dashboards/{board['id']}", headers=auth_headers,
|
|
json={'name': 'Attempted', 'widgets': [{'i': 'w1'}]})
|
|
assert resp.status_code == 400
|
|
|
|
unchanged = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
assert unchanged[0]['name'] == board['name']
|
|
assert unchanged[0]['widgets'] == board['widgets']
|
|
|
|
|
|
def test_widget_cfg_round_trips_verbatim(client, auth_headers):
|
|
cfg = {'metric': 'cpu', 'agg': 'p95', 'resource': '$server',
|
|
'thresholds': [70, 90], 'nested': {'deep': ['a', 1, None]}}
|
|
created = client.post('/api/v1/dashboards', headers=auth_headers, json={
|
|
'name': 'Cfg', 'widgets': [_widget(cfg=cfg)],
|
|
}).get_json()
|
|
assert created['widgets'][0]['cfg'] == cfg
|
|
|
|
reread = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
assert reread[-1]['widgets'][0]['cfg'] == cfg
|
|
|
|
|
|
# ── seeded cfg must match what the renderers actually read ───────────────────
|
|
# The shipped boards are the first thing every user sees, and a cfg key the
|
|
# frontend doesn't read fails SILENTLY — the widget just renders empty. That
|
|
# shipped once: `timeseries` seeded `metrics` while the renderer read `series`,
|
|
# and `actions` seeded `actions` while the renderer read `items`, so the default
|
|
# dashboard greeted everyone with a blank chart and "No actions selected".
|
|
#
|
|
# This table mirrors the `cfg.*` reads in
|
|
# frontend/src/components/dashboard/widgets/renderers.jsx and useWidgetData.js.
|
|
# If you change a widget's config shape there, change it here too — that is the
|
|
# coupling this test exists to make loud.
|
|
RENDERER_CFG_KEYS = {
|
|
'stat': {'required': {'metric', 'resource'}, 'known': {'metric', 'resource', 'agg', 'thresholds', 'spark', 'color', 'title'}},
|
|
'timeseries': {'required': {'series'}, 'known': {'series', 'legend', 'fill', 'title'}},
|
|
'gauge': {'required': {'metric', 'resource'}, 'known': {'metric', 'resource', 'agg', 'thresholds', 'title'}},
|
|
'topn': {'required': {'metric'}, 'known': {'metric', 'limit', 'title'}},
|
|
'table': {'required': {'source'}, 'known': {'source', 'limit', 'title'}},
|
|
'logs': {'required': {'source'}, 'known': {'source', 'path', 'containerId', 'lines', 'level', 'title'}},
|
|
'deploys': {'required': set(), 'known': {'limit', 'title'}},
|
|
'alerts': {'required': set(), 'known': {'limit', 'severity', 'title'}},
|
|
'status': {'required': {'source'}, 'known': {'source', 'limit', 'title'}},
|
|
'feed': {'required': set(), 'known': {'limit', 'title'}},
|
|
'actions': {'required': {'items'}, 'known': {'items', 'title'}},
|
|
'specs': {'required': {'resource'}, 'known': {'resource', 'title'}},
|
|
'clock': {'required': set(), 'known': {'timezone', 'showDate', 'showSeconds', 'title'}},
|
|
'note': {'required': set(), 'known': {'text', 'title'}},
|
|
}
|
|
|
|
# Shortcut keys the quick-actions widget can actually resolve to a route
|
|
# (ACTIONS in renderers.jsx). Anything else renders nothing.
|
|
QUICK_ACTION_KEYS = {
|
|
'servers', 'services', 'docker', 'terminal', 'deploys', 'databases',
|
|
'backups', 'monitoring', 'domains', 'files', 'security', 'jobs',
|
|
}
|
|
|
|
|
|
def test_seeded_widget_cfgs_match_the_renderer_contract():
|
|
from app.services.dashboard_service import DEFAULT_BOARDS
|
|
|
|
problems = []
|
|
for board in DEFAULT_BOARDS:
|
|
for widget in board['widgets']:
|
|
wtype = widget['type']
|
|
spec = RENDERER_CFG_KEYS.get(wtype)
|
|
if spec is None:
|
|
problems.append(f"{board['slug']}/{widget['i']}: unknown widget type {wtype!r}")
|
|
continue
|
|
keys = set((widget.get('cfg') or {}).keys())
|
|
missing = spec['required'] - keys
|
|
unread = keys - spec['known']
|
|
if missing:
|
|
problems.append(f"{board['slug']}/{widget['i']} ({wtype}): missing {sorted(missing)}")
|
|
if unread:
|
|
problems.append(f"{board['slug']}/{widget['i']} ({wtype}): nothing reads {sorted(unread)}")
|
|
|
|
assert not problems, 'seeded cfg drifted from the renderers:\n' + '\n'.join(problems)
|
|
|
|
|
|
def test_seeded_quick_actions_all_resolve():
|
|
"""An unrecognised shortcut key is filtered out, so a typo shows an empty
|
|
widget rather than an error."""
|
|
from app.services.dashboard_service import DEFAULT_BOARDS
|
|
|
|
for board in DEFAULT_BOARDS:
|
|
for widget in board['widgets']:
|
|
if widget['type'] != 'actions':
|
|
continue
|
|
items = set((widget.get('cfg') or {}).get('items') or [])
|
|
assert items, f"{board['slug']}/{widget['i']}: quick actions with no items"
|
|
unknown = items - QUICK_ACTION_KEYS
|
|
assert not unknown, (
|
|
f"{board['slug']}/{widget['i']}: unroutable shortcuts {sorted(unknown)}"
|
|
)
|
|
|
|
|
|
# ── repairing boards that predate the current renderer contract ──────────────
|
|
def test_legacy_board_is_repaired_on_read(app, client, auth_headers):
|
|
"""Fixing DEFAULT_BOARDS only helps users who have never opened the
|
|
dashboard — seeding never runs again for anyone else. Boards already in the
|
|
database carried `metrics`/`actions` keys nothing reads and no titles at
|
|
all, so every tile read "Stat" and the chart and shortcuts rendered empty.
|
|
"""
|
|
from app import db as _db
|
|
from app.models.dashboard import DashboardBoard
|
|
from app.models import User
|
|
|
|
user = User.query.filter_by(username='testadmin').first() or User.query.first()
|
|
legacy = DashboardBoard(
|
|
user_id=user.id, slug='overview', name='Overview', icon='grid', position=0,
|
|
widgets=[
|
|
{'i': 'w1', 'type': 'stat', 'x': 0, 'y': 0, 'w': 3, 'h': 2,
|
|
'cfg': {'metric': 'cpu', 'resource': '$server'}},
|
|
{'i': 'w5', 'type': 'timeseries', 'x': 0, 'y': 2, 'w': 8, 'h': 4,
|
|
'cfg': {'metrics': ['cpu', 'ram'], 'resource': '$server'}},
|
|
{'i': 'w6', 'type': 'actions', 'x': 8, 'y': 2, 'w': 4, 'h': 2,
|
|
'cfg': {'actions': ['new-app', 'deploy', 'terminal', 'backup']}},
|
|
])
|
|
_db.session.add(legacy)
|
|
_db.session.commit()
|
|
|
|
boards = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
board = next(b for b in boards if b['slug'] == 'overview')
|
|
by_id = {w['i']: w for w in board['widgets']}
|
|
|
|
# timeseries: metrics -> series, as [{resource, metric}]
|
|
ts = by_id['w5']['cfg']
|
|
assert 'metrics' not in ts
|
|
assert ts['series'] == [{'resource': '$server', 'metric': 'cpu'},
|
|
{'resource': '$server', 'metric': 'ram'}]
|
|
|
|
# actions: actions -> items, aliased to keys that actually route
|
|
items = by_id['w6']['cfg']['items']
|
|
assert 'actions' not in by_id['w6']['cfg']
|
|
assert items and all(i in {'servers', 'services', 'docker', 'terminal', 'deploys',
|
|
'databases', 'backups', 'monitoring', 'domains',
|
|
'files', 'security', 'jobs'} for i in items)
|
|
|
|
# Titles are NOT written into stored config — that would break the
|
|
# verbatim round-trip promise. The frame derives a display title instead.
|
|
assert 'title' not in by_id['w1']['cfg']
|
|
|
|
# and the repair is persisted, not recomputed on every read
|
|
stored = DashboardBoard.query.get(legacy.id)
|
|
assert 'series' in stored.widgets[1]['cfg']
|
|
|
|
|
|
def test_quick_actions_never_render_empty(app, client, auth_headers):
|
|
"""An empty shortcut list shows "No actions selected", which is a useless
|
|
thing to greet someone with — fall back to the shipped four."""
|
|
from app import db as _db
|
|
from app.models.dashboard import DashboardBoard
|
|
from app.models import User
|
|
|
|
user = User.query.filter_by(username='testadmin').first() or User.query.first()
|
|
board = DashboardBoard(
|
|
user_id=user.id, slug=None, name='Custom', icon='grid', position=9,
|
|
widgets=[{'i': 'a1', 'type': 'actions', 'x': 0, 'y': 0, 'w': 3, 'h': 2,
|
|
'cfg': {'items': ['nope', 'also-nope']}}])
|
|
_db.session.add(board)
|
|
_db.session.commit()
|
|
|
|
boards = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
custom = next(b for b in boards if b['name'] == 'Custom')
|
|
assert custom['widgets'][0]['cfg']['items'], 'unroutable shortcuts left the widget empty'
|
|
|
|
|
|
def test_repair_leaves_a_current_board_untouched(app, client, auth_headers):
|
|
"""The repair must be a no-op for config the renderers already understand."""
|
|
from app import db as _db
|
|
from app.models.dashboard import DashboardBoard
|
|
from app.models import User
|
|
|
|
user = User.query.filter_by(username='testadmin').first() or User.query.first()
|
|
cfg = {'title': 'Mine', 'series': [{'resource': 'local', 'metric': 'cpu'}],
|
|
'legend': True, 'fill': False}
|
|
board = DashboardBoard(
|
|
user_id=user.id, slug=None, name='Current', icon='grid', position=8,
|
|
widgets=[{'i': 't1', 'type': 'timeseries', 'x': 0, 'y': 0, 'w': 8, 'h': 4,
|
|
'cfg': dict(cfg)}])
|
|
_db.session.add(board)
|
|
_db.session.commit()
|
|
|
|
boards = client.get('/api/v1/dashboards', headers=auth_headers).get_json()['boards']
|
|
current = next(b for b in boards if b['name'] == 'Current')
|
|
assert current['widgets'][0]['cfg'] == cfg
|