Commit 660dc09f by Torkel Ödegaard

Merge branch 'master' of github.com:grafana/grafana

parents f0508aa5 f5cc7618
FROM nginx:alpine FROM nginx:alpine
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
\ No newline at end of file COPY htpasswd /etc/nginx/htpasswd
user1:$apr1$1odeeQb.$kwV8D/VAAGUDU7pnHuKoV0
user2:$apr1$A2kf25r.$6S0kp3C7vIuixS5CL0XA9.
admin:$apr1$IWn4DoRR$E2ol7fS/dkI18eU4bXnBO1
...@@ -13,7 +13,26 @@ http { ...@@ -13,7 +13,26 @@ http {
listen 10080; listen 10080;
location /grafana/ { location /grafana/ {
################################################################
# Enable these settings to test with basic auth and an auth proxy header
# the htpasswd file contains an admin user with password admin and
# user1: grafana and user2: grafana
################################################################
# auth_basic "Restricted Content";
# auth_basic_user_file /etc/nginx/htpasswd;
################################################################
# To use the auth proxy header, set the following in custom.ini:
# [auth.proxy]
# enabled = true
# header_name = X-WEBAUTH-USER
# header_property = username
################################################################
# proxy_set_header X-WEBAUTH-USER $remote_user;
proxy_pass http://localhost:3000/; proxy_pass http://localhost:3000/;
} }
} }
} }
\ No newline at end of file
+++
title = "Playlist HTTP API "
description = "Playlist Admin HTTP API"
keywords = ["grafana", "http", "documentation", "api", "playlist"]
aliases = ["/http_api/playlist/"]
type = "docs"
[menu.docs]
name = "Playlist"
parent = "http_api"
+++
# Playlist API
## Search Playlist
`GET /api/playlists`
Get all existing playlist for the current organization using pagination
**Example Request**:
```bash
GET /api/playlists HTTP/1.1
Accept: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
Querystring Parameters:
These parameters are used as querystring parameters.
- **query** - Limit response to playlist having a name like this value.
- **limit** - Limit response to *X* number of playlist.
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
[
{
"id": 1,
"name": "my playlist",
"interval": "5m"
}
]
```
## Get one playlist
`GET /api/playlists/:id`
**Example Request**:
```bash
GET /api/playlists/1 HTTP/1.1
Accept: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
{
"id" : 1,
"name": "my playlist",
"interval": "5m",
"orgId": "my org",
"items": [
{
"id": 1,
"playlistId": 1,
"type": "dashboard_by_id",
"value": "3",
"order": 1,
"title":"my third dasboard"
},
{
"id": 2,
"playlistId": 1,
"type": "dashboard_by_tag",
"value": "myTag",
"order": 2,
"title":"my other dasboard"
}
]
}
```
## Get Playlist items
`GET /api/playlists/:id/items`
**Example Request**:
```bash
GET /api/playlists/1/items HTTP/1.1
Accept: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
[
{
"id": 1,
"playlistId": 1,
"type": "dashboard_by_id",
"value": "3",
"order": 1,
"title":"my third dasboard"
},
{
"id": 2,
"playlistId": 1,
"type": "dashboard_by_tag",
"value": "myTag",
"order": 2,
"title":"my other dasboard"
}
]
```
## Get Playlist dashboards
`GET /api/playlists/:id/dashboards`
**Example Request**:
```bash
GET /api/playlists/1/dashboards HTTP/1.1
Accept: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
[
{
"id": 3,
"title": "my third dasboard",
"order": 1,
},
{
"id": 5,
"title":"my other dasboard"
"order": 2,
}
]
```
## Create a playlist
`POST /api/playlists/`
**Example Request**:
```bash
PUT /api/playlists/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
{
"name": "my playlist",
"interval": "5m",
"items": [
{
"type": "dashboard_by_id",
"value": "3",
"order": 1,
"title":"my third dasboard"
},
{
"type": "dashboard_by_tag",
"value": "myTag",
"order": 2,
"title":"my other dasboard"
}
]
}
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"name": "my playlist",
"interval": "5m"
}
```
## Update a playlist
`PUT /api/playlists/:id`
**Example Request**:
```bash
PUT /api/playlists/1 HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
{
"name": "my playlist",
"interval": "5m",
"items": [
{
"playlistId": 1,
"type": "dashboard_by_id",
"value": "3",
"order": 1,
"title":"my third dasboard"
},
{
"playlistId": 1,
"type": "dashboard_by_tag",
"value": "myTag",
"order": 2,
"title":"my other dasboard"
}
]
}
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
{
"id" : 1,
"name": "my playlist",
"interval": "5m",
"orgId": "my org",
"items": [
{
"id": 1,
"playlistId": 1,
"type": "dashboard_by_id",
"value": "3",
"order": 1,
"title":"my third dasboard"
},
{
"id": 2,
"playlistId": 1,
"type": "dashboard_by_tag",
"value": "myTag",
"order": 2,
"title":"my other dasboard"
}
]
}
```
## Delete a playlist
`DELETE /api/playlists/:id`
**Example Request**:
```bash
DELETE /api/playlists/1 HTTP/1.1
Accept: application/json
Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
```
**Example Response**:
```json
HTTP/1.1 200
Content-Type: application/json
{}
```
...@@ -863,7 +863,7 @@ Secret key. e.g. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ...@@ -863,7 +863,7 @@ Secret key. e.g. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Url to where Grafana will send PUT request with images Url to where Grafana will send PUT request with images
### public_url ### public_url
Optional parameter. Url to send to users in notifications, directly appended with the resulting uploaded file name. Optional parameter. Url to send to users in notifications. If the string contains the sequence ${file}, it will be replaced with the uploaded filename. Otherwise, the file name will be appended to the path part of the url, leaving any query string unchanged.
### username ### username
basic auth username basic auth username
......
...@@ -73,8 +73,7 @@ func (hs *HTTPServer) registerRoutes() { ...@@ -73,8 +73,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/dashboards/", reqSignedIn, Index) r.Get("/dashboards/", reqSignedIn, Index)
r.Get("/dashboards/*", reqSignedIn, Index) r.Get("/dashboards/*", reqSignedIn, Index)
r.Get("/explore/", reqEditorRole, Index) r.Get("/explore", reqEditorRole, Index)
r.Get("/explore/*", reqEditorRole, Index)
r.Get("/playlists/", reqSignedIn, Index) r.Get("/playlists/", reqSignedIn, Index)
r.Get("/playlists/*", reqSignedIn, Index) r.Get("/playlists/*", reqSignedIn, Index)
......
...@@ -160,6 +160,7 @@ func CreatePlaylist(c *m.ReqContext, cmd m.CreatePlaylistCommand) Response { ...@@ -160,6 +160,7 @@ func CreatePlaylist(c *m.ReqContext, cmd m.CreatePlaylistCommand) Response {
func UpdatePlaylist(c *m.ReqContext, cmd m.UpdatePlaylistCommand) Response { func UpdatePlaylist(c *m.ReqContext, cmd m.UpdatePlaylistCommand) Response {
cmd.OrgId = c.OrgId cmd.OrgId = c.OrgId
cmd.Id = c.ParamsInt64(":id")
if err := bus.Dispatch(&cmd); err != nil { if err := bus.Dispatch(&cmd); err != nil {
return Error(500, "Failed to save playlist", err) return Error(500, "Failed to save playlist", err)
......
...@@ -9,6 +9,7 @@ import ( ...@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"strings"
"time" "time"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
...@@ -35,6 +36,16 @@ var netClient = &http.Client{ ...@@ -35,6 +36,16 @@ var netClient = &http.Client{
Transport: netTransport, Transport: netTransport,
} }
func (u *WebdavUploader) PublicURL(filename string) string {
if strings.Contains(u.public_url, "${file}") {
return strings.Replace(u.public_url, "${file}", filename, -1)
} else {
publicURL, _ := url.Parse(u.public_url)
publicURL.Path = path.Join(publicURL.Path, filename)
return publicURL.String()
}
}
func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) { func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) {
url, _ := url.Parse(u.url) url, _ := url.Parse(u.url)
filename := util.GetRandomString(20) + ".png" filename := util.GetRandomString(20) + ".png"
...@@ -65,9 +76,7 @@ func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error) ...@@ -65,9 +76,7 @@ func (u *WebdavUploader) Upload(ctx context.Context, pa string) (string, error)
} }
if u.public_url != "" { if u.public_url != "" {
publicURL, _ := url.Parse(u.public_url) return u.PublicURL(filename), nil
publicURL.Path = path.Join(publicURL.Path, filename)
return publicURL.String(), nil
} }
return url.String(), nil return url.String(), nil
......
...@@ -2,6 +2,7 @@ package imguploader ...@@ -2,6 +2,7 @@ package imguploader
import ( import (
"context" "context"
"net/url"
"testing" "testing"
. "github.com/smartystreets/goconvey/convey" . "github.com/smartystreets/goconvey/convey"
...@@ -26,3 +27,15 @@ func TestUploadToWebdav(t *testing.T) { ...@@ -26,3 +27,15 @@ func TestUploadToWebdav(t *testing.T) {
So(path, ShouldStartWith, "http://publicurl:8888/webdav/") So(path, ShouldStartWith, "http://publicurl:8888/webdav/")
}) })
} }
func TestPublicURL(t *testing.T) {
Convey("Given a public URL with parameters, and no template", t, func() {
webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://cloudycloud.me/s/DOIFDOMV/download?files=")
parsed, _ := url.Parse(webdavUploader.PublicURL("fileyfile.png"))
So(parsed.Path, ShouldEndWith, "fileyfile.png")
})
Convey("Given a public URL with parameters, and a template", t, func() {
webdavUploader, _ := NewWebdavImageUploader("http://localhost:8888/webdav/", "test", "test", "http://cloudycloud.me/s/DOIFDOMV/download?files=${file}")
So(webdavUploader.PublicURL("fileyfile.png"), ShouldEndWith, "fileyfile.png")
})
}
...@@ -63,7 +63,7 @@ type PlaylistDashboards []*PlaylistDashboard ...@@ -63,7 +63,7 @@ type PlaylistDashboards []*PlaylistDashboard
type UpdatePlaylistCommand struct { type UpdatePlaylistCommand struct {
OrgId int64 `json:"-"` OrgId int64 `json:"-"`
Id int64 `json:"id" binding:"Required"` Id int64 `json:"id"`
Name string `json:"name" binding:"Required"` Name string `json:"name" binding:"Required"`
Interval string `json:"interval"` Interval string `json:"interval"`
Items []PlaylistItemDTO `json:"items"` Items []PlaylistItemDTO `json:"items"`
......
...@@ -73,6 +73,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error { ...@@ -73,6 +73,7 @@ func HandleAlertsQuery(query *m.GetAlertsQuery) error {
alert.name, alert.name,
alert.state, alert.state,
alert.new_state_date, alert.new_state_date,
alert.eval_data,
alert.eval_date, alert.eval_date,
alert.execution_error, alert.execution_error,
dashboard.uid as dashboard_uid, dashboard.uid as dashboard_uid,
......
...@@ -13,7 +13,7 @@ func mockTimeNow() { ...@@ -13,7 +13,7 @@ func mockTimeNow() {
var timeSeed int64 var timeSeed int64
timeNow = func() time.Time { timeNow = func() time.Time {
fakeNow := time.Unix(timeSeed, 0) fakeNow := time.Unix(timeSeed, 0)
timeSeed += 1 timeSeed++
return fakeNow return fakeNow
} }
} }
...@@ -30,7 +30,7 @@ func TestAlertingDataAccess(t *testing.T) { ...@@ -30,7 +30,7 @@ func TestAlertingDataAccess(t *testing.T) {
InitTestDB(t) InitTestDB(t)
testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert") testDash := insertTestDashboard("dashboard with alerts", 1, 0, false, "alert")
evalData, _ := simplejson.NewJson([]byte(`{"test": "test"}`))
items := []*m.Alert{ items := []*m.Alert{
{ {
PanelId: 1, PanelId: 1,
...@@ -40,6 +40,7 @@ func TestAlertingDataAccess(t *testing.T) { ...@@ -40,6 +40,7 @@ func TestAlertingDataAccess(t *testing.T) {
Message: "Alerting message", Message: "Alerting message",
Settings: simplejson.New(), Settings: simplejson.New(),
Frequency: 1, Frequency: 1,
EvalData: evalData,
}, },
} }
...@@ -104,8 +105,18 @@ func TestAlertingDataAccess(t *testing.T) { ...@@ -104,8 +105,18 @@ func TestAlertingDataAccess(t *testing.T) {
alert := alertQuery.Result[0] alert := alertQuery.Result[0]
So(err2, ShouldBeNil) So(err2, ShouldBeNil)
So(alert.Id, ShouldBeGreaterThan, 0)
So(alert.DashboardId, ShouldEqual, testDash.Id)
So(alert.PanelId, ShouldEqual, 1)
So(alert.Name, ShouldEqual, "Alerting title") So(alert.Name, ShouldEqual, "Alerting title")
So(alert.State, ShouldEqual, "pending") So(alert.State, ShouldEqual, "pending")
So(alert.NewStateDate, ShouldNotBeNil)
So(alert.EvalData, ShouldNotBeNil)
So(alert.EvalData.Get("test").MustString(), ShouldEqual, "test")
So(alert.EvalDate, ShouldNotBeNil)
So(alert.ExecutionError, ShouldEqual, "")
So(alert.DashboardUid, ShouldNotBeNil)
So(alert.DashboardSlug, ShouldEqual, "dashboard-with-alerts")
}) })
Convey("Viewer cannot read alerts", func() { Convey("Viewer cannot read alerts", func() {
......
...@@ -2,16 +2,18 @@ import React from 'react'; ...@@ -2,16 +2,18 @@ import React from 'react';
import { hot } from 'react-hot-loader'; import { hot } from 'react-hot-loader';
import Select from 'react-select'; import Select from 'react-select';
import kbn from 'app/core/utils/kbn';
import colors from 'app/core/utils/colors'; import colors from 'app/core/utils/colors';
import TimeSeries from 'app/core/time_series2'; import TimeSeries from 'app/core/time_series2';
import { decodePathComponent } from 'app/core/utils/location_util'; import { decodePathComponent } from 'app/core/utils/location_util';
import { parse as parseDate } from 'app/core/utils/datemath';
import ElapsedTime from './ElapsedTime'; import ElapsedTime from './ElapsedTime';
import QueryRows from './QueryRows'; import QueryRows from './QueryRows';
import Graph from './Graph'; import Graph from './Graph';
import Table from './Table'; import Table from './Table';
import TimePicker, { DEFAULT_RANGE } from './TimePicker'; import TimePicker, { DEFAULT_RANGE } from './TimePicker';
import { buildQueryOptions, ensureQueries, generateQueryKey, hasQuery } from './utils/query'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
function makeTimeSeriesList(dataList, options) { function makeTimeSeriesList(dataList, options) {
return dataList.map((seriesData, index) => { return dataList.map((seriesData, index) => {
...@@ -31,18 +33,20 @@ function makeTimeSeriesList(dataList, options) { ...@@ -31,18 +33,20 @@ function makeTimeSeriesList(dataList, options) {
}); });
} }
function parseInitialState(initial) { function parseInitialState(initial: string | undefined) {
try { if (initial) {
const parsed = JSON.parse(decodePathComponent(initial)); try {
return { const parsed = JSON.parse(decodePathComponent(initial));
datasource: parsed.datasource, return {
queries: parsed.queries.map(q => q.query), datasource: parsed.datasource,
range: parsed.range, queries: parsed.queries.map(q => q.query),
}; range: parsed.range,
} catch (e) { };
console.error(e); } catch (e) {
return { queries: [], range: DEFAULT_RANGE }; console.error(e);
}
} }
return { datasource: null, queries: [], range: DEFAULT_RANGE };
} }
interface IExploreState { interface IExploreState {
...@@ -63,11 +67,12 @@ interface IExploreState { ...@@ -63,11 +67,12 @@ interface IExploreState {
tableResult: any; tableResult: any;
} }
// @observer
export class Explore extends React.Component<any, IExploreState> { export class Explore extends React.Component<any, IExploreState> {
el: any;
constructor(props) { constructor(props) {
super(props); super(props);
const { datasource, queries, range } = parseInitialState(props.routeParams.initial); const { datasource, queries, range } = parseInitialState(props.routeParams.state);
this.state = { this.state = {
datasource: null, datasource: null,
datasourceError: null, datasourceError: null,
...@@ -132,6 +137,10 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -132,6 +137,10 @@ export class Explore extends React.Component<any, IExploreState> {
} }
} }
getRef = el => {
this.el = el;
};
handleAddQueryRow = index => { handleAddQueryRow = index => {
const { queries } = this.state; const { queries } = this.state;
const nextQueries = [ const nextQueries = [
...@@ -214,20 +223,33 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -214,20 +223,33 @@ export class Explore extends React.Component<any, IExploreState> {
} }
}; };
async runGraphQuery() { buildQueryOptions(targetOptions: { format: string; instant: boolean }) {
const { datasource, queries, range } = this.state; const { datasource, queries, range } = this.state;
const resolution = this.el.offsetWidth;
const absoluteRange = {
from: parseDate(range.from, false),
to: parseDate(range.to, true),
};
const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
const targets = queries.map(q => ({
...targetOptions,
expr: q.query,
}));
return {
interval,
range,
targets,
};
}
async runGraphQuery() {
const { datasource, queries } = this.state;
if (!hasQuery(queries)) { if (!hasQuery(queries)) {
return; return;
} }
this.setState({ latency: 0, loading: true, graphResult: null, queryError: null }); this.setState({ latency: 0, loading: true, graphResult: null, queryError: null });
const now = Date.now(); const now = Date.now();
const options = buildQueryOptions({ const options = this.buildQueryOptions({ format: 'time_series', instant: false });
format: 'time_series',
interval: datasource.interval,
instant: false,
range,
queries: queries.map(q => q.query),
});
try { try {
const res = await datasource.query(options); const res = await datasource.query(options);
const result = makeTimeSeriesList(res.data, options); const result = makeTimeSeriesList(res.data, options);
...@@ -241,18 +263,15 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -241,18 +263,15 @@ export class Explore extends React.Component<any, IExploreState> {
} }
async runTableQuery() { async runTableQuery() {
const { datasource, queries, range } = this.state; const { datasource, queries } = this.state;
if (!hasQuery(queries)) { if (!hasQuery(queries)) {
return; return;
} }
this.setState({ latency: 0, loading: true, queryError: null, tableResult: null }); this.setState({ latency: 0, loading: true, queryError: null, tableResult: null });
const now = Date.now(); const now = Date.now();
const options = buildQueryOptions({ const options = this.buildQueryOptions({
format: 'table', format: 'table',
interval: datasource.interval,
instant: true, instant: true,
range,
queries: queries.map(q => q.query),
}); });
try { try {
const res = await datasource.query(options); const res = await datasource.query(options);
...@@ -301,7 +320,7 @@ export class Explore extends React.Component<any, IExploreState> { ...@@ -301,7 +320,7 @@ export class Explore extends React.Component<any, IExploreState> {
const selectedDatasource = datasource ? datasource.name : undefined; const selectedDatasource = datasource ? datasource.name : undefined;
return ( return (
<div className={exploreClass}> <div className={exploreClass} ref={this.getRef}>
<div className="navbar"> <div className="navbar">
{position === 'left' ? ( {position === 'left' ? (
<div> <div>
......
export function buildQueryOptions({ format, interval, instant, range, queries }) {
return {
interval,
range,
targets: queries.map(expr => ({
expr,
format,
instant,
})),
};
}
export function generateQueryKey(index = 0) { export function generateQueryKey(index = 0) {
return `Q-${Date.now()}-${Math.random()}-${index}`; return `Q-${Date.now()}-${Math.random()}-${index}`;
} }
......
...@@ -191,7 +191,7 @@ export class KeybindingSrv { ...@@ -191,7 +191,7 @@ export class KeybindingSrv {
range, range,
}; };
const exploreState = encodePathComponent(JSON.stringify(state)); const exploreState = encodePathComponent(JSON.stringify(state));
this.$location.url(`/explore/${exploreState}`); this.$location.url(`/explore?state=${exploreState}`);
} }
} }
}); });
......
...@@ -332,7 +332,7 @@ class MetricsPanelCtrl extends PanelCtrl { ...@@ -332,7 +332,7 @@ class MetricsPanelCtrl extends PanelCtrl {
range, range,
}; };
const exploreState = encodePathComponent(JSON.stringify(state)); const exploreState = encodePathComponent(JSON.stringify(state));
this.$location.url(`/explore/${exploreState}`); this.$location.url(`/explore?state=${exploreState}`);
} }
addQuery(target) { addQuery(target) {
......
...@@ -112,7 +112,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { ...@@ -112,7 +112,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
controller: 'FolderDashboardsCtrl', controller: 'FolderDashboardsCtrl',
controllerAs: 'ctrl', controllerAs: 'ctrl',
}) })
.when('/explore/:initial?', { .when('/explore', {
template: '<react-container />', template: '<react-container />',
resolve: { resolve: {
roles: () => ['Editor', 'Admin'], roles: () => ['Editor', 'Admin'],
......
...@@ -24,7 +24,7 @@ small { ...@@ -24,7 +24,7 @@ small {
font-size: 85%; font-size: 85%;
} }
strong { strong {
font-weight: bold; font-weight: $font-weight-semi-bold;
} }
em { em {
font-style: italic; font-style: italic;
...@@ -249,7 +249,7 @@ dd { ...@@ -249,7 +249,7 @@ dd {
line-height: $line-height-base; line-height: $line-height-base;
} }
dt { dt {
font-weight: bold; font-weight: $font-weight-semi-bold;
} }
dd { dd {
margin-left: $line-height-base / 2; margin-left: $line-height-base / 2;
...@@ -376,7 +376,7 @@ a.external-link { ...@@ -376,7 +376,7 @@ a.external-link {
padding: $spacer*0.5 $spacer; padding: $spacer*0.5 $spacer;
} }
th { th {
font-weight: normal; font-weight: $font-weight-semi-bold;
background: $table-bg-accent; background: $table-bg-accent;
} }
} }
...@@ -415,3 +415,7 @@ a.external-link { ...@@ -415,3 +415,7 @@ a.external-link {
color: $yellow; color: $yellow;
padding: 0; padding: 0;
} }
th {
font-weight: $font-weight-semi-bold;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment