mirror of
https://github.com/grafana/grafana.git
synced 2025-01-01 11:47:05 -06:00
Explore WIP
This commit is contained in:
parent
1dd4f03100
commit
f1220fd2a4
@ -22,6 +22,7 @@
|
||||
"axios": "^0.17.1",
|
||||
"babel-core": "^6.26.0",
|
||||
"babel-loader": "^7.1.2",
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"babel-preset-es2015": "^6.24.1",
|
||||
"clean-webpack-plugin": "^0.1.19",
|
||||
"css-loader": "^0.28.7",
|
||||
@ -150,6 +151,7 @@
|
||||
"d3-scale-chromatic": "^1.1.1",
|
||||
"eventemitter3": "^2.0.3",
|
||||
"file-saver": "^1.3.3",
|
||||
"immutable": "^3.8.2",
|
||||
"jquery": "^3.2.1",
|
||||
"lodash": "^4.17.4",
|
||||
"mobx": "^3.4.1",
|
||||
@ -158,6 +160,7 @@
|
||||
"moment": "^2.18.1",
|
||||
"mousetrap": "^1.6.0",
|
||||
"mousetrap-global-bind": "^1.1.0",
|
||||
"prismjs": "^1.6.0",
|
||||
"prop-types": "^15.6.0",
|
||||
"react": "^16.2.0",
|
||||
"react-dom": "^16.2.0",
|
||||
@ -170,6 +173,9 @@
|
||||
"remarkable": "^1.7.1",
|
||||
"rst2html": "github:thoward/rst2html#990cb89",
|
||||
"rxjs": "^5.4.3",
|
||||
"slate": "^0.33.4",
|
||||
"slate-plain-serializer": "^0.5.10",
|
||||
"slate-react": "^0.12.4",
|
||||
"tether": "^1.4.0",
|
||||
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
|
||||
"tinycolor2": "^1.4.1"
|
||||
|
@ -117,6 +117,17 @@ func setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, error) {
|
||||
Children: dashboardChildNavs,
|
||||
})
|
||||
|
||||
// data.NavTree = append(data.NavTree, &dtos.NavLink{
|
||||
// Text: "Explore",
|
||||
// Id: "explore",
|
||||
// SubTitle: "Explore your data",
|
||||
// Icon: "fa fa-rocket",
|
||||
// Url: setting.AppSubUrl + "/explore",
|
||||
// Children: []*dtos.NavLink{
|
||||
// {Text: "New tab", Icon: "gicon gicon-dashboard-new", Url: setting.AppSubUrl + "/explore/new"},
|
||||
// },
|
||||
// })
|
||||
|
||||
if c.IsSignedIn {
|
||||
// Only set login if it's different from the name
|
||||
var login string
|
||||
|
46
public/app/containers/Explore/ElapsedTime.tsx
Normal file
46
public/app/containers/Explore/ElapsedTime.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
const INTERVAL = 150;
|
||||
|
||||
export default class ElapsedTime extends PureComponent<any, any> {
|
||||
offset: number;
|
||||
timer: NodeJS.Timer;
|
||||
|
||||
state = {
|
||||
elapsed: 0,
|
||||
};
|
||||
|
||||
start() {
|
||||
this.offset = Date.now();
|
||||
this.timer = setInterval(this.tick, INTERVAL);
|
||||
}
|
||||
|
||||
tick = () => {
|
||||
const jetzt = Date.now();
|
||||
const elapsed = jetzt - this.offset;
|
||||
this.setState({ elapsed });
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.time) {
|
||||
clearInterval(this.timer);
|
||||
} else if (this.props.time) {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.start();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { elapsed } = this.state;
|
||||
const { className, time } = this.props;
|
||||
const value = (time || elapsed) / 1000;
|
||||
return <span className={className}>{value.toFixed(1)}s</span>;
|
||||
}
|
||||
}
|
246
public/app/containers/Explore/Explore.tsx
Normal file
246
public/app/containers/Explore/Explore.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
import React from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import colors from 'app/core/utils/colors';
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
|
||||
import ElapsedTime from './ElapsedTime';
|
||||
import Legend from './Legend';
|
||||
import QueryField from './QueryField';
|
||||
import Graph from './Graph';
|
||||
import Table from './Table';
|
||||
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
|
||||
function buildQueryOptions({ format, interval, instant, now, query }) {
|
||||
const to = now;
|
||||
const from = to - 1000 * 60 * 60 * 3;
|
||||
return {
|
||||
interval,
|
||||
range: {
|
||||
from,
|
||||
to,
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
expr: query,
|
||||
format,
|
||||
instant,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function makeTimeSeriesList(dataList, options) {
|
||||
return dataList.map((seriesData, index) => {
|
||||
const datapoints = seriesData.datapoints || [];
|
||||
const alias = seriesData.target;
|
||||
|
||||
const colorIndex = index % colors.length;
|
||||
const color = colors[colorIndex];
|
||||
|
||||
const series = new TimeSeries({
|
||||
datapoints: datapoints,
|
||||
alias: alias,
|
||||
color: color,
|
||||
unit: seriesData.unit,
|
||||
});
|
||||
|
||||
if (datapoints && datapoints.length > 0) {
|
||||
const last = datapoints[datapoints.length - 1][1];
|
||||
const from = options.range.from;
|
||||
if (last - from < -10000) {
|
||||
series.isOutsideRange = true;
|
||||
}
|
||||
}
|
||||
|
||||
return series;
|
||||
});
|
||||
}
|
||||
|
||||
interface IExploreState {
|
||||
datasource: any;
|
||||
datasourceError: any;
|
||||
datasourceLoading: any;
|
||||
graphResult: any;
|
||||
latency: number;
|
||||
loading: any;
|
||||
requestOptions: any;
|
||||
showingGraph: boolean;
|
||||
showingTable: boolean;
|
||||
tableResult: any;
|
||||
}
|
||||
|
||||
// @observer
|
||||
export class Explore extends React.Component<any, IExploreState> {
|
||||
datasourceSrv: DatasourceSrv;
|
||||
query: string;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
datasource: null,
|
||||
datasourceError: null,
|
||||
datasourceLoading: true,
|
||||
graphResult: null,
|
||||
latency: 0,
|
||||
loading: false,
|
||||
requestOptions: null,
|
||||
showingGraph: true,
|
||||
showingTable: true,
|
||||
tableResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const datasource = await this.props.datasourceSrv.get();
|
||||
const testResult = await datasource.testDatasource();
|
||||
if (testResult.status === 'success') {
|
||||
this.setState({ datasource, datasourceError: null, datasourceLoading: false });
|
||||
} else {
|
||||
this.setState({ datasource: null, datasourceError: testResult.message, datasourceLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleClickGraphButton = () => {
|
||||
this.setState(state => ({ showingGraph: !state.showingGraph }));
|
||||
};
|
||||
|
||||
handleClickTableButton = () => {
|
||||
this.setState(state => ({ showingTable: !state.showingTable }));
|
||||
};
|
||||
|
||||
handleRequestError({ error }) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
handleQueryChange = query => {
|
||||
this.query = query;
|
||||
};
|
||||
|
||||
handleSubmit = () => {
|
||||
const { showingGraph, showingTable } = this.state;
|
||||
if (showingTable) {
|
||||
this.runTableQuery();
|
||||
}
|
||||
if (showingGraph) {
|
||||
this.runGraphQuery();
|
||||
}
|
||||
};
|
||||
|
||||
async runGraphQuery() {
|
||||
const { query } = this;
|
||||
const { datasource } = this.state;
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
this.setState({ latency: 0, loading: true, graphResult: null });
|
||||
const now = Date.now();
|
||||
const options = buildQueryOptions({
|
||||
format: 'time_series',
|
||||
interval: datasource.interval,
|
||||
instant: false,
|
||||
now,
|
||||
query,
|
||||
});
|
||||
try {
|
||||
const res = await datasource.query(options);
|
||||
const result = makeTimeSeriesList(res.data, options);
|
||||
const latency = Date.now() - now;
|
||||
this.setState({ latency, loading: false, graphResult: result, requestOptions: options });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.setState({ loading: false, graphResult: error });
|
||||
}
|
||||
}
|
||||
|
||||
async runTableQuery() {
|
||||
const { query } = this;
|
||||
const { datasource } = this.state;
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
this.setState({ latency: 0, loading: true, tableResult: null });
|
||||
const now = Date.now();
|
||||
const options = buildQueryOptions({ format: 'table', interval: datasource.interval, instant: true, now, query });
|
||||
try {
|
||||
const res = await datasource.query(options);
|
||||
const tableModel = res.data[0];
|
||||
const latency = Date.now() - now;
|
||||
this.setState({ latency, loading: false, tableResult: tableModel, requestOptions: options });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.setState({ loading: false, tableResult: null });
|
||||
}
|
||||
}
|
||||
|
||||
request = url => {
|
||||
const { datasource } = this.state;
|
||||
return datasource.metadataRequest(url);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
datasource,
|
||||
datasourceError,
|
||||
datasourceLoading,
|
||||
latency,
|
||||
loading,
|
||||
requestOptions,
|
||||
graphResult,
|
||||
showingGraph,
|
||||
showingTable,
|
||||
tableResult,
|
||||
} = this.state;
|
||||
const showingBoth = showingGraph && showingTable;
|
||||
const graphHeight = showingBoth ? '200px' : null;
|
||||
const graphButtonClassName = showingBoth || showingGraph ? 'btn m-r-1' : 'btn btn-inverse m-r-1';
|
||||
const tableButtonClassName = showingBoth || showingTable ? 'btn m-r-1' : 'btn btn-inverse m-r-1';
|
||||
return (
|
||||
<div className="explore">
|
||||
<div className="page-body page-full">
|
||||
<h2 className="page-sub-heading">Explore</h2>
|
||||
{datasourceLoading ? <div>Loading datasource...</div> : null}
|
||||
|
||||
{datasourceError ? <div title={datasourceError}>Error connecting to datasource.</div> : null}
|
||||
|
||||
{datasource ? (
|
||||
<div className="m-r-3">
|
||||
<div className="nav m-b-1">
|
||||
<div className="pull-right" style={{ paddingRight: '6rem' }}>
|
||||
<button type="submit" className="m-l-1 btn btn-primary" onClick={this.handleSubmit}>
|
||||
<i className="fa fa-return" /> Run Query
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button className={graphButtonClassName} onClick={this.handleClickGraphButton}>
|
||||
Graph
|
||||
</button>
|
||||
<button className={tableButtonClassName} onClick={this.handleClickTableButton}>
|
||||
Table
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="query-field-wrapper">
|
||||
<QueryField
|
||||
request={this.request}
|
||||
onPressEnter={this.handleSubmit}
|
||||
onQueryChange={this.handleQueryChange}
|
||||
onRequestError={this.handleRequestError}
|
||||
/>
|
||||
</div>
|
||||
{loading || latency ? <ElapsedTime time={latency} className="m-l-1" /> : null}
|
||||
<main className="m-t-2">
|
||||
{showingGraph ? (
|
||||
<Graph data={graphResult} id="explore-1" options={requestOptions} height={graphHeight} />
|
||||
) : null}
|
||||
{showingGraph ? <Legend data={graphResult} /> : null}
|
||||
{showingTable ? <Table data={tableResult} className="m-t-3" /> : null}
|
||||
</main>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default hot(module)(Explore);
|
123
public/app/containers/Explore/Graph.tsx
Normal file
123
public/app/containers/Explore/Graph.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import $ from 'jquery';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import TimeSeries from 'app/core/time_series2';
|
||||
|
||||
import 'vendor/flot/jquery.flot';
|
||||
import 'vendor/flot/jquery.flot.time';
|
||||
|
||||
// Copied from graph.ts
|
||||
function time_format(ticks, min, max) {
|
||||
if (min && max && ticks) {
|
||||
var range = max - min;
|
||||
var secPerTick = range / ticks / 1000;
|
||||
var oneDay = 86400000;
|
||||
var oneYear = 31536000000;
|
||||
|
||||
if (secPerTick <= 45) {
|
||||
return '%H:%M:%S';
|
||||
}
|
||||
if (secPerTick <= 7200 || range <= oneDay) {
|
||||
return '%H:%M';
|
||||
}
|
||||
if (secPerTick <= 80000) {
|
||||
return '%m/%d %H:%M';
|
||||
}
|
||||
if (secPerTick <= 2419200 || range <= oneYear) {
|
||||
return '%m/%d';
|
||||
}
|
||||
return '%Y-%m';
|
||||
}
|
||||
|
||||
return '%H:%M';
|
||||
}
|
||||
|
||||
const FLOT_OPTIONS = {
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
series: {
|
||||
lines: {
|
||||
linewidth: 1,
|
||||
zero: false,
|
||||
},
|
||||
shadowSize: 0,
|
||||
},
|
||||
grid: {
|
||||
minBorderMargin: 0,
|
||||
markings: [],
|
||||
backgroundColor: null,
|
||||
borderWidth: 0,
|
||||
// hoverable: true,
|
||||
clickable: true,
|
||||
color: '#a1a1a1',
|
||||
margin: { left: 0, right: 0 },
|
||||
labelMarginX: 0,
|
||||
},
|
||||
// selection: {
|
||||
// mode: 'x',
|
||||
// color: '#666',
|
||||
// },
|
||||
// crosshair: {
|
||||
// mode: 'x',
|
||||
// },
|
||||
};
|
||||
|
||||
class Graph extends Component<any, any> {
|
||||
componentDidMount() {
|
||||
this.draw();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.data !== this.props.data ||
|
||||
prevProps.options !== this.props.options ||
|
||||
prevProps.height !== this.props.height
|
||||
) {
|
||||
this.draw();
|
||||
}
|
||||
}
|
||||
|
||||
draw() {
|
||||
const { data, options: userOptions } = this.props;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const series = data.map((ts: TimeSeries) => ({
|
||||
label: ts.label,
|
||||
data: ts.getFlotPairs('null'),
|
||||
}));
|
||||
|
||||
const $el = $(`#${this.props.id}`);
|
||||
const ticks = $el.width() / 100;
|
||||
const min = userOptions.range.from.valueOf();
|
||||
const max = userOptions.range.to.valueOf();
|
||||
const dynamicOptions = {
|
||||
xaxis: {
|
||||
mode: 'time',
|
||||
min: min,
|
||||
max: max,
|
||||
label: 'Datetime',
|
||||
ticks: ticks,
|
||||
timeformat: time_format(ticks, min, max),
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
...FLOT_OPTIONS,
|
||||
...dynamicOptions,
|
||||
...userOptions,
|
||||
};
|
||||
$.plot($el, series, options);
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
height: this.props.height || '400px',
|
||||
width: this.props.width || '100%',
|
||||
};
|
||||
|
||||
return <div id={this.props.id} style={style} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default Graph;
|
22
public/app/containers/Explore/Legend.tsx
Normal file
22
public/app/containers/Explore/Legend.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
const LegendItem = ({ series }) => (
|
||||
<div className="graph-legend-series">
|
||||
<div className="graph-legend-icon">
|
||||
<i className="fa fa-minus pointer" style={{ color: series.color }} />
|
||||
</div>
|
||||
<a className="graph-legend-alias pointer">{series.alias}</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default class Legend extends PureComponent<any, any> {
|
||||
render() {
|
||||
const { className = '', data } = this.props;
|
||||
const items = data || [];
|
||||
return (
|
||||
<div className={`${className} graph-legend ps`}>
|
||||
{items.map(series => <LegendItem key={series.id} series={series} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
562
public/app/containers/Explore/QueryField.tsx
Normal file
562
public/app/containers/Explore/QueryField.tsx
Normal file
@ -0,0 +1,562 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Value } from 'slate';
|
||||
import { Editor } from 'slate-react';
|
||||
import Plain from 'slate-plain-serializer';
|
||||
|
||||
// dom also includes Element polyfills
|
||||
import { getNextCharacter, getPreviousCousin } from './utils/dom';
|
||||
import BracesPlugin from './slate-plugins/braces';
|
||||
import ClearPlugin from './slate-plugins/clear';
|
||||
import NewlinePlugin from './slate-plugins/newline';
|
||||
import PluginPrism, { configurePrismMetricsTokens } from './slate-plugins/prism/index';
|
||||
import RunnerPlugin from './slate-plugins/runner';
|
||||
import debounce from './utils/debounce';
|
||||
import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
|
||||
|
||||
import Typeahead from './Typeahead';
|
||||
|
||||
const EMPTY_METRIC = '';
|
||||
const TYPEAHEAD_DEBOUNCE = 300;
|
||||
|
||||
function flattenSuggestions(s) {
|
||||
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
|
||||
}
|
||||
|
||||
const getInitialValue = query =>
|
||||
Value.fromJSON({
|
||||
document: {
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'paragraph',
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [
|
||||
{
|
||||
text: query,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
class Portal extends React.Component {
|
||||
node: any;
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.node = document.createElement('div');
|
||||
this.node.classList.add(`query-field-portal-${props.index}`);
|
||||
document.body.appendChild(this.node);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.body.removeChild(this.node);
|
||||
}
|
||||
|
||||
render() {
|
||||
return ReactDOM.createPortal(this.props.children, this.node);
|
||||
}
|
||||
}
|
||||
|
||||
class QueryField extends React.Component<any, any> {
|
||||
menuEl: any;
|
||||
plugins: any;
|
||||
resetTimer: any;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.plugins = [
|
||||
BracesPlugin(),
|
||||
ClearPlugin(),
|
||||
RunnerPlugin({ handler: props.onPressEnter }),
|
||||
NewlinePlugin(),
|
||||
PluginPrism(),
|
||||
];
|
||||
|
||||
this.state = {
|
||||
labelKeys: {},
|
||||
labelValues: {},
|
||||
metrics: props.metrics || [],
|
||||
suggestions: [],
|
||||
typeaheadIndex: 0,
|
||||
typeaheadPrefix: '',
|
||||
value: getInitialValue(props.initialQuery || ''),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateMenu();
|
||||
|
||||
if (this.props.metrics === undefined) {
|
||||
this.fetchMetricNames();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearTimeout(this.resetTimer);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateMenu();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.metrics && nextProps.metrics !== this.props.metrics) {
|
||||
this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived);
|
||||
}
|
||||
// initialQuery is null in case the user typed
|
||||
if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
|
||||
this.setState({ value: getInitialValue(nextProps.initialQuery) });
|
||||
}
|
||||
}
|
||||
|
||||
onChange = ({ value }) => {
|
||||
const changed = value.document !== this.state.value.document;
|
||||
this.setState({ value }, () => {
|
||||
if (changed) {
|
||||
this.handleChangeQuery();
|
||||
}
|
||||
});
|
||||
|
||||
window.requestAnimationFrame(this.handleTypeahead);
|
||||
};
|
||||
|
||||
onMetricsReceived = () => {
|
||||
if (!this.state.metrics) {
|
||||
return;
|
||||
}
|
||||
configurePrismMetricsTokens(this.state.metrics);
|
||||
// Trigger re-render
|
||||
window.requestAnimationFrame(() => {
|
||||
// Bogus edit to trigger highlighting
|
||||
const change = this.state.value
|
||||
.change()
|
||||
.insertText(' ')
|
||||
.deleteBackward(1);
|
||||
this.onChange(change);
|
||||
});
|
||||
};
|
||||
|
||||
request = url => {
|
||||
if (this.props.request) {
|
||||
return this.props.request(url);
|
||||
}
|
||||
return fetch(url);
|
||||
};
|
||||
|
||||
handleChangeQuery = () => {
|
||||
// Send text change to parent
|
||||
const { onQueryChange } = this.props;
|
||||
if (onQueryChange) {
|
||||
onQueryChange(Plain.serialize(this.state.value));
|
||||
}
|
||||
};
|
||||
|
||||
handleTypeahead = debounce(() => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.anchorNode) {
|
||||
const wrapperNode = selection.anchorNode.parentElement;
|
||||
const editorNode = wrapperNode.closest('.query-field');
|
||||
if (!editorNode || this.state.value.isBlurred) {
|
||||
// Not inside this editor
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = selection.anchorNode.textContent;
|
||||
const offset = range.startOffset;
|
||||
const prefix = cleanText(text.substr(0, offset));
|
||||
|
||||
// Determine candidates by context
|
||||
const suggestionGroups = [];
|
||||
const wrapperClasses = wrapperNode.classList;
|
||||
let typeaheadContext = null;
|
||||
|
||||
// Take first metric as lucky guess
|
||||
const metricNode = editorNode.querySelector('.metric');
|
||||
|
||||
if (wrapperClasses.contains('context-range')) {
|
||||
// Rate ranges
|
||||
typeaheadContext = 'context-range';
|
||||
suggestionGroups.push({
|
||||
label: 'Range vector',
|
||||
items: [...RATE_RANGES],
|
||||
});
|
||||
} else if (wrapperClasses.contains('context-labels') && metricNode) {
|
||||
const metric = metricNode.textContent;
|
||||
const labelKeys = this.state.labelKeys[metric];
|
||||
if (labelKeys) {
|
||||
if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
|
||||
// Label values
|
||||
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
||||
if (labelKeyNode) {
|
||||
const labelKey = labelKeyNode.textContent;
|
||||
const labelValues = this.state.labelValues[metric][labelKey];
|
||||
typeaheadContext = 'context-label-values';
|
||||
suggestionGroups.push({
|
||||
label: 'Label values',
|
||||
items: labelValues,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Label keys
|
||||
typeaheadContext = 'context-labels';
|
||||
suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
||||
}
|
||||
} else {
|
||||
this.fetchMetricLabels(metric);
|
||||
}
|
||||
} else if (wrapperClasses.contains('context-labels') && !metricNode) {
|
||||
// Empty name queries
|
||||
const defaultKeys = ['job', 'instance'];
|
||||
// Munge all keys that we have seen together
|
||||
const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
|
||||
return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
|
||||
}, defaultKeys);
|
||||
if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
|
||||
// Label values
|
||||
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
||||
if (labelKeyNode) {
|
||||
const labelKey = labelKeyNode.textContent;
|
||||
if (this.state.labelValues[EMPTY_METRIC]) {
|
||||
const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
|
||||
typeaheadContext = 'context-label-values';
|
||||
suggestionGroups.push({
|
||||
label: 'Label values',
|
||||
items: labelValues,
|
||||
});
|
||||
} else {
|
||||
// Can only query label values for now (API to query keys is under development)
|
||||
this.fetchLabelValues(labelKey);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Label keys
|
||||
typeaheadContext = 'context-labels';
|
||||
suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
||||
}
|
||||
} else if (metricNode && wrapperClasses.contains('context-aggregation')) {
|
||||
typeaheadContext = 'context-aggregation';
|
||||
const metric = metricNode.textContent;
|
||||
const labelKeys = this.state.labelKeys[metric];
|
||||
if (labelKeys) {
|
||||
suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
||||
} else {
|
||||
this.fetchMetricLabels(metric);
|
||||
}
|
||||
} else if (
|
||||
(this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) ||
|
||||
wrapperClasses.contains('context-function')
|
||||
) {
|
||||
// Need prefix for metrics
|
||||
typeaheadContext = 'context-metrics';
|
||||
suggestionGroups.push({
|
||||
label: 'Metrics',
|
||||
items: this.state.metrics,
|
||||
});
|
||||
}
|
||||
|
||||
let results = 0;
|
||||
const filteredSuggestions = suggestionGroups.map(group => {
|
||||
if (group.items) {
|
||||
group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1);
|
||||
results += group.items.length;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
|
||||
console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
|
||||
|
||||
this.setState({
|
||||
typeaheadPrefix: prefix,
|
||||
typeaheadContext,
|
||||
typeaheadText: text,
|
||||
suggestions: results > 0 ? filteredSuggestions : [],
|
||||
});
|
||||
}
|
||||
}, TYPEAHEAD_DEBOUNCE);
|
||||
|
||||
applyTypeahead(change, suggestion) {
|
||||
const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
|
||||
|
||||
// Modify suggestion based on context
|
||||
switch (typeaheadContext) {
|
||||
case 'context-labels': {
|
||||
const nextChar = getNextCharacter();
|
||||
if (!nextChar || nextChar === '}' || nextChar === ',') {
|
||||
suggestion += '=';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'context-label-values': {
|
||||
// Always add quotes and remove existing ones instead
|
||||
if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
|
||||
suggestion = `"${suggestion}`;
|
||||
}
|
||||
if (getNextCharacter() !== '"') {
|
||||
suggestion = `${suggestion}"`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
this.resetTypeahead();
|
||||
|
||||
// Remove the current, incomplete text and replace it with the selected suggestion
|
||||
let backward = typeaheadPrefix.length;
|
||||
const text = cleanText(typeaheadText);
|
||||
const suffixLength = text.length - typeaheadPrefix.length;
|
||||
const offset = typeaheadText.indexOf(typeaheadPrefix);
|
||||
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText);
|
||||
const forward = midWord ? suffixLength + offset : 0;
|
||||
|
||||
return (
|
||||
change
|
||||
// TODO this line breaks if cursor was moved left and length is longer than whole prefix
|
||||
.deleteBackward(backward)
|
||||
.deleteForward(forward)
|
||||
.insertText(suggestion)
|
||||
.focus()
|
||||
);
|
||||
}
|
||||
|
||||
onKeyDown = (event, change) => {
|
||||
if (this.menuEl) {
|
||||
const { typeaheadIndex, suggestions } = this.state;
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape': {
|
||||
if (this.menuEl) {
|
||||
event.preventDefault();
|
||||
this.resetTypeahead();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Tab': {
|
||||
// Dont blur input
|
||||
event.preventDefault();
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the currently selected suggestion
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions);
|
||||
const selected = Math.abs(typeaheadIndex);
|
||||
const selectedIndex = selected % flattenedSuggestions.length || 0;
|
||||
const suggestion = flattenedSuggestions[selectedIndex];
|
||||
|
||||
this.applyTypeahead(change, suggestion);
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'ArrowDown': {
|
||||
// Select next suggestion
|
||||
event.preventDefault();
|
||||
this.setState({ typeaheadIndex: typeaheadIndex + 1 });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ArrowUp': {
|
||||
// Select previous suggestion
|
||||
event.preventDefault();
|
||||
this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
// console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
resetTypeahead = () => {
|
||||
this.setState({
|
||||
suggestions: [],
|
||||
typeaheadIndex: 0,
|
||||
typeaheadPrefix: '',
|
||||
typeaheadContext: null,
|
||||
});
|
||||
};
|
||||
|
||||
async fetchLabelValues(key) {
|
||||
const url = `/api/v1/label/${key}/values`;
|
||||
try {
|
||||
const res = await this.request(url);
|
||||
const body = await (res.data || res.json());
|
||||
const pairs = this.state.labelValues[EMPTY_METRIC];
|
||||
const values = {
|
||||
...pairs,
|
||||
[key]: body.data,
|
||||
};
|
||||
// const labelKeys = {
|
||||
// ...this.state.labelKeys,
|
||||
// [EMPTY_METRIC]: keys,
|
||||
// };
|
||||
const labelValues = {
|
||||
...this.state.labelValues,
|
||||
[EMPTY_METRIC]: values,
|
||||
};
|
||||
this.setState({ labelValues }, this.handleTypeahead);
|
||||
} catch (e) {
|
||||
if (this.props.onRequestError) {
|
||||
this.props.onRequestError(e);
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetricLabels(name) {
|
||||
const url = `/api/v1/series?match[]=${name}`;
|
||||
try {
|
||||
const res = await this.request(url);
|
||||
const body = await (res.data || res.json());
|
||||
const { keys, values } = processLabels(body.data);
|
||||
const labelKeys = {
|
||||
...this.state.labelKeys,
|
||||
[name]: keys,
|
||||
};
|
||||
const labelValues = {
|
||||
...this.state.labelValues,
|
||||
[name]: values,
|
||||
};
|
||||
this.setState({ labelKeys, labelValues }, this.handleTypeahead);
|
||||
} catch (e) {
|
||||
if (this.props.onRequestError) {
|
||||
this.props.onRequestError(e);
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetchMetricNames() {
|
||||
const url = '/api/v1/label/__name__/values';
|
||||
try {
|
||||
const res = await this.request(url);
|
||||
const body = await (res.data || res.json());
|
||||
this.setState({ metrics: body.data }, this.onMetricsReceived);
|
||||
} catch (error) {
|
||||
if (this.props.onRequestError) {
|
||||
this.props.onRequestError(error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
const { onBlur } = this.props;
|
||||
// If we dont wait here, menu clicks wont work because the menu
|
||||
// will be gone.
|
||||
this.resetTimer = setTimeout(this.resetTypeahead, 100);
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
};
|
||||
|
||||
handleFocus = () => {
|
||||
const { onFocus } = this.props;
|
||||
if (onFocus) {
|
||||
onFocus();
|
||||
}
|
||||
};
|
||||
|
||||
handleClickMenu = item => {
|
||||
// Manually triggering change
|
||||
const change = this.applyTypeahead(this.state.value.change(), item);
|
||||
this.onChange(change);
|
||||
};
|
||||
|
||||
updateMenu = () => {
|
||||
const { suggestions } = this.state;
|
||||
const menu = this.menuEl;
|
||||
const selection = window.getSelection();
|
||||
const node = selection.anchorNode;
|
||||
|
||||
// No menu, nothing to do
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No suggestions or blur, remove menu
|
||||
const hasSuggesstions = suggestions && suggestions.length > 0;
|
||||
if (!hasSuggesstions) {
|
||||
menu.removeAttribute('style');
|
||||
return;
|
||||
}
|
||||
|
||||
// Align menu overlay to editor node
|
||||
if (node) {
|
||||
const rect = node.parentElement.getBoundingClientRect();
|
||||
menu.style.opacity = 1;
|
||||
menu.style.top = `${rect.top + window.scrollY + rect.height + 4}px`;
|
||||
menu.style.left = `${rect.left + window.scrollX - 2}px`;
|
||||
}
|
||||
};
|
||||
|
||||
menuRef = el => {
|
||||
this.menuEl = el;
|
||||
};
|
||||
|
||||
renderMenu = () => {
|
||||
const { suggestions } = this.state;
|
||||
const hasSuggesstions = suggestions && suggestions.length > 0;
|
||||
if (!hasSuggesstions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Guard selectedIndex to be within the length of the suggestions
|
||||
let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions);
|
||||
selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
|
||||
const selectedKeys = flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : [];
|
||||
|
||||
// Create typeahead in DOM root so we can later position it absolutely
|
||||
return (
|
||||
<Portal>
|
||||
<Typeahead
|
||||
menuRef={this.menuRef}
|
||||
selectedItems={selectedKeys}
|
||||
onClickItem={this.handleClickMenu}
|
||||
groupedItems={suggestions}
|
||||
/>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="query-field">
|
||||
{this.renderMenu()}
|
||||
<Editor
|
||||
autoCorrect={false}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onChange={this.onChange}
|
||||
onFocus={this.handleFocus}
|
||||
placeholder={this.props.placeholder}
|
||||
plugins={this.plugins}
|
||||
spellCheck={false}
|
||||
value={this.state.value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default QueryField;
|
24
public/app/containers/Explore/Table.tsx
Normal file
24
public/app/containers/Explore/Table.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
// import TableModel from 'app/core/table_model';
|
||||
|
||||
const EMPTY_TABLE = {
|
||||
columns: [],
|
||||
rows: [],
|
||||
};
|
||||
|
||||
export default class Table extends PureComponent<any, any> {
|
||||
render() {
|
||||
const { className = '', data } = this.props;
|
||||
const tableModel = data || EMPTY_TABLE;
|
||||
return (
|
||||
<table className={`${className} filter-table`}>
|
||||
<thead>
|
||||
<tr>{tableModel.columns.map(col => <th key={col.text}>{col.text}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableModel.rows.map((row, i) => <tr key={i}>{row.map((content, j) => <td key={j}>{content}</td>)}</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
}
|
66
public/app/containers/Explore/Typeahead.tsx
Normal file
66
public/app/containers/Explore/Typeahead.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
|
||||
function scrollIntoView(el) {
|
||||
if (!el || !el.offsetParent) {
|
||||
return;
|
||||
}
|
||||
const container = el.offsetParent;
|
||||
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) {
|
||||
container.scrollTop = el.offsetTop - container.offsetTop;
|
||||
}
|
||||
}
|
||||
|
||||
class TypeaheadItem extends React.PureComponent<any, any> {
|
||||
el: any;
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.isSelected && !prevProps.isSelected) {
|
||||
scrollIntoView(this.el);
|
||||
}
|
||||
}
|
||||
|
||||
getRef = el => {
|
||||
this.el = el;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isSelected, label, onClickItem } = this.props;
|
||||
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item';
|
||||
const onClick = () => onClickItem(label);
|
||||
return (
|
||||
<li ref={this.getRef} className={className} onClick={onClick}>
|
||||
{label}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TypeaheadGroup extends React.PureComponent<any, any> {
|
||||
render() {
|
||||
const { items, label, selected, onClickItem } = this.props;
|
||||
return (
|
||||
<li className="typeahead-group">
|
||||
<div className="typeahead-group__title">{label}</div>
|
||||
<ul className="typeahead-group__list">
|
||||
{items.map(item => (
|
||||
<TypeaheadItem key={item} onClickItem={onClickItem} isSelected={selected.indexOf(item) > -1} label={item} />
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Typeahead extends React.PureComponent<any, any> {
|
||||
render() {
|
||||
const { groupedItems, menuRef, selectedItems, onClickItem } = this.props;
|
||||
return (
|
||||
<ul className="typeahead" ref={menuRef}>
|
||||
{groupedItems.map(g => (
|
||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Typeahead;
|
47
public/app/containers/Explore/slate-plugins/braces.test.ts
Normal file
47
public/app/containers/Explore/slate-plugins/braces.test.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import Plain from 'slate-plain-serializer';
|
||||
|
||||
import BracesPlugin from './braces';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
KeyboardEvent: any;
|
||||
}
|
||||
}
|
||||
|
||||
describe('braces', () => {
|
||||
const handler = BracesPlugin().onKeyDown;
|
||||
|
||||
it('adds closing braces around empty value', () => {
|
||||
const change = Plain.deserialize('').change();
|
||||
const event = new window.KeyboardEvent('keydown', { key: '(' });
|
||||
handler(event, change);
|
||||
expect(Plain.serialize(change.value)).toEqual('()');
|
||||
});
|
||||
|
||||
it('adds closing braces around a value', () => {
|
||||
const change = Plain.deserialize('foo').change();
|
||||
const event = new window.KeyboardEvent('keydown', { key: '(' });
|
||||
handler(event, change);
|
||||
expect(Plain.serialize(change.value)).toEqual('(foo)');
|
||||
});
|
||||
|
||||
it('adds closing braces around the following value only', () => {
|
||||
const change = Plain.deserialize('foo bar ugh').change();
|
||||
let event;
|
||||
event = new window.KeyboardEvent('keydown', { key: '(' });
|
||||
handler(event, change);
|
||||
expect(Plain.serialize(change.value)).toEqual('(foo) bar ugh');
|
||||
|
||||
// Wrap bar
|
||||
change.move(5);
|
||||
event = new window.KeyboardEvent('keydown', { key: '(' });
|
||||
handler(event, change);
|
||||
expect(Plain.serialize(change.value)).toEqual('(foo) (bar) ugh');
|
||||
|
||||
// Create empty parens after (bar)
|
||||
change.move(4);
|
||||
event = new window.KeyboardEvent('keydown', { key: '(' });
|
||||
handler(event, change);
|
||||
expect(Plain.serialize(change.value)).toEqual('(foo) (bar)() ugh');
|
||||
});
|
||||
});
|
51
public/app/containers/Explore/slate-plugins/braces.ts
Normal file
51
public/app/containers/Explore/slate-plugins/braces.ts
Normal file
@ -0,0 +1,51 @@
|
||||
const BRACES = {
|
||||
'[': ']',
|
||||
'{': '}',
|
||||
'(': ')',
|
||||
};
|
||||
|
||||
export default function BracesPlugin() {
|
||||
return {
|
||||
onKeyDown(event, change) {
|
||||
const { value } = change;
|
||||
if (!value.isCollapsed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case '{':
|
||||
case '[': {
|
||||
event.preventDefault();
|
||||
// Insert matching braces
|
||||
change
|
||||
.insertText(`${event.key}${BRACES[event.key]}`)
|
||||
.move(-1)
|
||||
.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
case '(': {
|
||||
event.preventDefault();
|
||||
const text = value.anchorText.text;
|
||||
const offset = value.anchorOffset;
|
||||
const space = text.indexOf(' ', offset);
|
||||
const length = space > 0 ? space : text.length;
|
||||
const forward = length - offset;
|
||||
// Insert matching braces
|
||||
change
|
||||
.insertText(event.key)
|
||||
.move(forward)
|
||||
.insertText(BRACES[event.key])
|
||||
.move(-1 - forward)
|
||||
.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
38
public/app/containers/Explore/slate-plugins/clear.test.ts
Normal file
38
public/app/containers/Explore/slate-plugins/clear.test.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import Plain from 'slate-plain-serializer';
|
||||
|
||||
import ClearPlugin from './clear';
|
||||
|
||||
describe('clear', () => {
|
||||
const handler = ClearPlugin().onKeyDown;
|
||||
|
||||
it('does not change the empty value', () => {
|
||||
const change = Plain.deserialize('').change();
|
||||
const event = new window.KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
ctrlKey: true,
|
||||
});
|
||||
handler(event, change);
|
||||
expect(Plain.serialize(change.value)).toEqual('');
|
||||
});
|
||||
|
||||
it('clears to the end of the line', () => {
|
||||
const change = Plain.deserialize('foo').change();
|
||||
const event = new window.KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
ctrlKey: true,
|
||||
});
|
||||
handler(event, change);
|
||||
expect(Plain.serialize(change.value)).toEqual('');
|
||||
});
|
||||
|
||||
it('clears from the middle to the end of the line', () => {
|
||||
const change = Plain.deserialize('foo bar').change();
|
||||
change.move(4);
|
||||
const event = new window.KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
ctrlKey: true,
|
||||
});
|
||||
handler(event, change);
|
||||
expect(Plain.serialize(change.value)).toEqual('foo ');
|
||||
});
|
||||
});
|
22
public/app/containers/Explore/slate-plugins/clear.ts
Normal file
22
public/app/containers/Explore/slate-plugins/clear.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// Clears the rest of the line after the caret
|
||||
export default function ClearPlugin() {
|
||||
return {
|
||||
onKeyDown(event, change) {
|
||||
const { value } = change;
|
||||
if (!value.isCollapsed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (event.key === 'k' && event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
const text = value.anchorText.text;
|
||||
const offset = value.anchorOffset;
|
||||
const length = text.length;
|
||||
const forward = length - offset;
|
||||
change.deleteForward(forward);
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
35
public/app/containers/Explore/slate-plugins/newline.ts
Normal file
35
public/app/containers/Explore/slate-plugins/newline.ts
Normal file
@ -0,0 +1,35 @@
|
||||
function getIndent(text) {
|
||||
let offset = text.length - text.trimLeft().length;
|
||||
if (offset) {
|
||||
let indent = text[0];
|
||||
while (--offset) {
|
||||
indent += text[0];
|
||||
}
|
||||
return indent;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function NewlinePlugin() {
|
||||
return {
|
||||
onKeyDown(event, change) {
|
||||
const { value } = change;
|
||||
if (!value.isCollapsed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
const { startBlock } = value;
|
||||
const currentLineText = startBlock.text;
|
||||
const indent = getIndent(currentLineText);
|
||||
|
||||
return change
|
||||
.splitBlock()
|
||||
.insertText(indent)
|
||||
.focus();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
122
public/app/containers/Explore/slate-plugins/prism/index.tsx
Normal file
122
public/app/containers/Explore/slate-plugins/prism/index.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import Prism from 'prismjs';
|
||||
|
||||
import Promql from './promql';
|
||||
|
||||
Prism.languages.promql = Promql;
|
||||
|
||||
const TOKEN_MARK = 'prism-token';
|
||||
|
||||
export function configurePrismMetricsTokens(metrics) {
|
||||
Prism.languages.promql.metric = {
|
||||
alias: 'variable',
|
||||
pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Code-highlighting plugin based on Prism and
|
||||
* https://github.com/ianstormtaylor/slate/blob/master/examples/code-highlighting/index.js
|
||||
*
|
||||
* (Adapted to handle nested grammar definitions.)
|
||||
*/
|
||||
|
||||
export default function PrismPlugin() {
|
||||
return {
|
||||
/**
|
||||
* Render a Slate mark with appropiate CSS class names
|
||||
*
|
||||
* @param {Object} props
|
||||
* @return {Element}
|
||||
*/
|
||||
|
||||
renderMark(props) {
|
||||
const { children, mark } = props;
|
||||
// Only apply spans to marks identified by this plugin
|
||||
if (mark.type !== TOKEN_MARK) {
|
||||
return undefined;
|
||||
}
|
||||
const className = `token ${mark.data.get('types')}`;
|
||||
return <span className={className}>{children}</span>;
|
||||
},
|
||||
|
||||
/**
|
||||
* Decorate code blocks with Prism.js highlighting.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @return {Array}
|
||||
*/
|
||||
|
||||
decorateNode(node) {
|
||||
if (node.type !== 'paragraph') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const texts = node.getTexts().toArray();
|
||||
const tstring = texts.map(t => t.text).join('\n');
|
||||
const grammar = Prism.languages.promql;
|
||||
const tokens = Prism.tokenize(tstring, grammar);
|
||||
const decorations = [];
|
||||
let startText = texts.shift();
|
||||
let endText = startText;
|
||||
let startOffset = 0;
|
||||
let endOffset = 0;
|
||||
let start = 0;
|
||||
|
||||
function processToken(token, acc?) {
|
||||
// Accumulate token types down the tree
|
||||
const types = `${acc || ''} ${token.type || ''} ${token.alias || ''}`;
|
||||
|
||||
// Add mark for token node
|
||||
if (typeof token === 'string' || typeof token.content === 'string') {
|
||||
startText = endText;
|
||||
startOffset = endOffset;
|
||||
|
||||
const content = typeof token === 'string' ? token : token.content;
|
||||
const newlines = content.split('\n').length - 1;
|
||||
const length = content.length - newlines;
|
||||
const end = start + length;
|
||||
|
||||
let available = startText.text.length - startOffset;
|
||||
let remaining = length;
|
||||
|
||||
endOffset = startOffset + remaining;
|
||||
|
||||
while (available < remaining) {
|
||||
endText = texts.shift();
|
||||
remaining = length - available;
|
||||
available = endText.text.length;
|
||||
endOffset = remaining;
|
||||
}
|
||||
|
||||
// Inject marks from up the tree (acc) as well
|
||||
if (typeof token !== 'string' || acc) {
|
||||
const range = {
|
||||
anchorKey: startText.key,
|
||||
anchorOffset: startOffset,
|
||||
focusKey: endText.key,
|
||||
focusOffset: endOffset,
|
||||
marks: [{ type: TOKEN_MARK, data: { types } }],
|
||||
};
|
||||
|
||||
decorations.push(range);
|
||||
}
|
||||
|
||||
start = end;
|
||||
} else if (token.content && token.content.length) {
|
||||
// Tokens can be nested
|
||||
for (const subToken of token.content) {
|
||||
processToken(subToken, types);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process top-level tokens
|
||||
for (const token of tokens) {
|
||||
processToken(token);
|
||||
}
|
||||
|
||||
return decorations;
|
||||
},
|
||||
};
|
||||
}
|
123
public/app/containers/Explore/slate-plugins/prism/promql.ts
Normal file
123
public/app/containers/Explore/slate-plugins/prism/promql.ts
Normal file
@ -0,0 +1,123 @@
|
||||
export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
|
||||
|
||||
const AGGREGATION_OPERATORS = [
|
||||
'sum',
|
||||
'min',
|
||||
'max',
|
||||
'avg',
|
||||
'stddev',
|
||||
'stdvar',
|
||||
'count',
|
||||
'count_values',
|
||||
'bottomk',
|
||||
'topk',
|
||||
'quantile',
|
||||
];
|
||||
|
||||
export const FUNCTIONS = [
|
||||
...AGGREGATION_OPERATORS,
|
||||
'abs',
|
||||
'absent',
|
||||
'ceil',
|
||||
'changes',
|
||||
'clamp_max',
|
||||
'clamp_min',
|
||||
'count_scalar',
|
||||
'day_of_month',
|
||||
'day_of_week',
|
||||
'days_in_month',
|
||||
'delta',
|
||||
'deriv',
|
||||
'drop_common_labels',
|
||||
'exp',
|
||||
'floor',
|
||||
'histogram_quantile',
|
||||
'holt_winters',
|
||||
'hour',
|
||||
'idelta',
|
||||
'increase',
|
||||
'irate',
|
||||
'label_replace',
|
||||
'ln',
|
||||
'log2',
|
||||
'log10',
|
||||
'minute',
|
||||
'month',
|
||||
'predict_linear',
|
||||
'rate',
|
||||
'resets',
|
||||
'round',
|
||||
'scalar',
|
||||
'sort',
|
||||
'sort_desc',
|
||||
'sqrt',
|
||||
'time',
|
||||
'vector',
|
||||
'year',
|
||||
'avg_over_time',
|
||||
'min_over_time',
|
||||
'max_over_time',
|
||||
'sum_over_time',
|
||||
'count_over_time',
|
||||
'quantile_over_time',
|
||||
'stddev_over_time',
|
||||
'stdvar_over_time',
|
||||
];
|
||||
|
||||
const tokenizer = {
|
||||
comment: {
|
||||
pattern: /(^|[^\n])#.*/,
|
||||
lookbehind: true,
|
||||
},
|
||||
'context-aggregation': {
|
||||
pattern: /((by|without)\s*)\([^)]*\)/, // by ()
|
||||
lookbehind: true,
|
||||
inside: {
|
||||
'label-key': {
|
||||
pattern: /[^,\s][^,]*[^,\s]*/,
|
||||
alias: 'attr-name',
|
||||
},
|
||||
},
|
||||
},
|
||||
'context-labels': {
|
||||
pattern: /\{[^}]*(?=})/,
|
||||
inside: {
|
||||
'label-key': {
|
||||
pattern: /[a-z_]\w*(?=\s*(=|!=|=~|!~))/,
|
||||
alias: 'attr-name',
|
||||
},
|
||||
'label-value': {
|
||||
pattern: /"(?:\\.|[^\\"])*"/,
|
||||
greedy: true,
|
||||
alias: 'attr-value',
|
||||
},
|
||||
},
|
||||
},
|
||||
function: new RegExp(`\\b(?:${FUNCTIONS.join('|')})(?=\\s*\\()`, 'i'),
|
||||
'context-range': [
|
||||
{
|
||||
pattern: /\[[^\]]*(?=])/, // [1m]
|
||||
inside: {
|
||||
'range-duration': {
|
||||
pattern: /\b\d+[smhdwy]\b/i,
|
||||
alias: 'number',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /(offset\s+)\w+/, // offset 1m
|
||||
lookbehind: true,
|
||||
inside: {
|
||||
'range-duration': {
|
||||
pattern: /\b\d+[smhdwy]\b/i,
|
||||
alias: 'number',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/,
|
||||
operator: new RegExp(`/[-+*/=%^~]|&&?|\\|?\\||!=?|<(?:=>?|<|>)?|>[>=]?|\\b(?:${OPERATORS.join('|')})\\b`, 'i'),
|
||||
punctuation: /[{};()`,.]/,
|
||||
};
|
||||
|
||||
export default tokenizer;
|
14
public/app/containers/Explore/slate-plugins/runner.ts
Normal file
14
public/app/containers/Explore/slate-plugins/runner.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export default function RunnerPlugin({ handler }) {
|
||||
return {
|
||||
onKeyDown(event) {
|
||||
// Handle enter
|
||||
if (handler && event.key === 'Enter' && !event.shiftKey) {
|
||||
// Submit on Enter
|
||||
event.preventDefault();
|
||||
handler(event);
|
||||
return true;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
14
public/app/containers/Explore/utils/debounce.ts
Normal file
14
public/app/containers/Explore/utils/debounce.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// Based on underscore.js debounce()
|
||||
export default function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function() {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
const later = function() {
|
||||
timeout = null;
|
||||
func.apply(context, args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
40
public/app/containers/Explore/utils/dom.ts
Normal file
40
public/app/containers/Explore/utils/dom.ts
Normal file
@ -0,0 +1,40 @@
|
||||
// Node.closest() polyfill
|
||||
if ('Element' in window && !Element.prototype.closest) {
|
||||
Element.prototype.closest = function(s) {
|
||||
const matches = (this.document || this.ownerDocument).querySelectorAll(s);
|
||||
let el = this;
|
||||
let i;
|
||||
// eslint-disable-next-line
|
||||
do {
|
||||
i = matches.length;
|
||||
// eslint-disable-next-line
|
||||
while (--i >= 0 && matches.item(i) !== el) {}
|
||||
} while (i < 0 && (el = el.parentElement));
|
||||
return el;
|
||||
};
|
||||
}
|
||||
|
||||
export function getPreviousCousin(node, selector) {
|
||||
let sibling = node.parentElement.previousSibling;
|
||||
let el;
|
||||
while (sibling) {
|
||||
el = sibling.querySelector(selector);
|
||||
if (el) {
|
||||
return el;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getNextCharacter(global = window) {
|
||||
const selection = global.getSelection();
|
||||
if (!selection.anchorNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = selection.anchorNode.textContent;
|
||||
const offset = range.startOffset;
|
||||
return text.substr(offset, 1);
|
||||
}
|
20
public/app/containers/Explore/utils/prometheus.ts
Normal file
20
public/app/containers/Explore/utils/prometheus.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export const RATE_RANGES = ['1m', '5m', '10m', '30m', '1h'];
|
||||
|
||||
export function processLabels(labels) {
|
||||
const values = {};
|
||||
labels.forEach(l => {
|
||||
const { __name__, ...rest } = l;
|
||||
Object.keys(rest).forEach(key => {
|
||||
if (!values[key]) {
|
||||
values[key] = [];
|
||||
}
|
||||
if (values[key].indexOf(rest[key]) === -1) {
|
||||
values[key].push(rest[key]);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { values, keys: Object.keys(values) };
|
||||
}
|
||||
|
||||
// Strip syntax chars
|
||||
export const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
|
@ -8,11 +8,23 @@ import appEvents from 'app/core/app_events';
|
||||
import Drop from 'tether-drop';
|
||||
import { createStore } from 'app/stores/store';
|
||||
import colors from 'app/core/utils/colors';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
|
||||
export class GrafanaCtrl {
|
||||
/** @ngInject */
|
||||
constructor($scope, alertSrv, utilSrv, $rootScope, $controller, contextSrv, bridgeSrv, backendSrv) {
|
||||
createStore(backendSrv);
|
||||
constructor(
|
||||
$scope,
|
||||
alertSrv,
|
||||
utilSrv,
|
||||
$rootScope,
|
||||
$controller,
|
||||
contextSrv,
|
||||
bridgeSrv,
|
||||
backendSrv: BackendSrv,
|
||||
datasourceSrv: DatasourceSrv
|
||||
) {
|
||||
createStore({ backendSrv, datasourceSrv });
|
||||
|
||||
$scope.init = function() {
|
||||
$scope.contextSrv = contextSrv;
|
||||
|
@ -15,7 +15,7 @@ export class DatasourceSrv {
|
||||
this.datasources = {};
|
||||
}
|
||||
|
||||
get(name) {
|
||||
get(name?) {
|
||||
if (!name) {
|
||||
return this.get(config.defaultDatasource);
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Provider } from 'mobx-react';
|
||||
|
||||
import coreModule from 'app/core/core_module';
|
||||
import { store } from 'app/stores/store';
|
||||
import { Provider } from 'mobx-react';
|
||||
import { BackendSrv } from 'app/core/services/backend_srv';
|
||||
import { DatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
|
||||
function WrapInProvider(store, Component, props) {
|
||||
return (
|
||||
@ -13,14 +16,15 @@ function WrapInProvider(store, Component, props) {
|
||||
}
|
||||
|
||||
/** @ngInject */
|
||||
export function reactContainer($route, $location, backendSrv) {
|
||||
export function reactContainer($route, $location, backendSrv: BackendSrv, datasourceSrv: DatasourceSrv) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '',
|
||||
link(scope, elem) {
|
||||
let component = $route.current.locals.component;
|
||||
let component = $route.current.locals.component.default;
|
||||
let props = {
|
||||
backendSrv: backendSrv,
|
||||
datasourceSrv: datasourceSrv,
|
||||
};
|
||||
|
||||
ReactDOM.render(WrapInProvider(store, component, props), elem[0]);
|
||||
|
@ -1,7 +1,9 @@
|
||||
import './dashboard_loaders';
|
||||
import './ReactContainer';
|
||||
|
||||
import ServerStats from 'app/containers/ServerStats/ServerStats';
|
||||
import AlertRuleList from 'app/containers/AlertRuleList/AlertRuleList';
|
||||
// import Explore from 'app/containers/Explore/Explore';
|
||||
import FolderSettings from 'app/containers/ManageDashboards/FolderSettings';
|
||||
import FolderPermissions from 'app/containers/ManageDashboards/FolderPermissions';
|
||||
|
||||
@ -109,6 +111,12 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
|
||||
controller: 'FolderDashboardsCtrl',
|
||||
controllerAs: 'ctrl',
|
||||
})
|
||||
.when('/explore', {
|
||||
template: '<react-container />',
|
||||
resolve: {
|
||||
component: () => import(/* webpackChunkName: "explore" */ 'app/containers/Explore/Explore'),
|
||||
},
|
||||
})
|
||||
.when('/org', {
|
||||
templateUrl: 'public/app/features/org/partials/orgDetails.html',
|
||||
controller: 'OrgDetailsCtrl',
|
||||
|
@ -3,11 +3,11 @@ import config from 'app/core/config';
|
||||
|
||||
export let store: IRootStore;
|
||||
|
||||
export function createStore(backendSrv) {
|
||||
export function createStore(services) {
|
||||
store = RootStore.create(
|
||||
{},
|
||||
{
|
||||
backendSrv: backendSrv,
|
||||
...services,
|
||||
navTree: config.bootData.navTree,
|
||||
}
|
||||
);
|
||||
|
@ -104,5 +104,6 @@
|
||||
@import 'pages/signup';
|
||||
@import 'pages/styleguide';
|
||||
@import 'pages/errorpage';
|
||||
@import 'pages/explore';
|
||||
@import 'old_responsive';
|
||||
@import 'components/view_states.scss';
|
||||
|
@ -23,6 +23,13 @@
|
||||
@include clearfix();
|
||||
}
|
||||
|
||||
.page-full {
|
||||
margin-left: $page-sidebar-margin;
|
||||
padding-left: $spacer;
|
||||
padding-right: $spacer;
|
||||
@include clearfix();
|
||||
}
|
||||
|
||||
.scroll-canvas {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
|
304
public/sass/pages/_explore.scss
Normal file
304
public/sass/pages/_explore.scss
Normal file
@ -0,0 +1,304 @@
|
||||
.explore {
|
||||
.graph-legend {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.query-field {
|
||||
font-size: 14px;
|
||||
font-family: Consolas, Menlo, Courier, monospace;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.query-field-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
padding: 6px 7px 4px;
|
||||
width: calc(100% - 6rem);
|
||||
cursor: text;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background-color: #fff;
|
||||
background-image: none;
|
||||
border: 1px solid lightgray;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.typeahead {
|
||||
position: absolute;
|
||||
z-index: auto;
|
||||
top: -10000px;
|
||||
left: -10000px;
|
||||
opacity: 0;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.75s;
|
||||
border: 1px solid #e4e4e4;
|
||||
max-height: calc(66vh);
|
||||
overflow-y: scroll;
|
||||
max-width: calc(66%);
|
||||
overflow-x: hidden;
|
||||
outline: none;
|
||||
list-style: none;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
transition: opacity 0.4s ease-out;
|
||||
}
|
||||
|
||||
.typeahead-group__title {
|
||||
color: rgba(0, 0, 0, 0.43);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.typeahead-item {
|
||||
line-height: 200%;
|
||||
height: auto;
|
||||
font-family: Consolas, Menlo, Courier, monospace;
|
||||
padding: 0 16px 0 28px;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
margin-left: -1px;
|
||||
left: 1px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
|
||||
background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
}
|
||||
|
||||
.typeahead-item__selected {
|
||||
background-color: #ecf6fd;
|
||||
color: #108ee9;
|
||||
}
|
||||
|
||||
/* SYNTAX */
|
||||
|
||||
/**
|
||||
* prism.js Coy theme for JavaScript, CoffeeScript, CSS and HTML
|
||||
* Based on https://github.com/tshedor/workshop-wp-theme (Example: http://workshop.kansan.com/category/sessions/basics or http://workshop.timshedor.com/category/sessions/basics);
|
||||
* @author Tim Shedor
|
||||
*/
|
||||
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
color: black;
|
||||
background: none;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
word-wrap: normal;
|
||||
line-height: 1.5;
|
||||
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*='language-'] {
|
||||
position: relative;
|
||||
margin: 0.5em 0;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
pre[class*='language-'] > code {
|
||||
position: relative;
|
||||
border-left: 10px solid #358ccb;
|
||||
box-shadow: -1px 0px 0px 0px #358ccb, 0px 0px 0px 1px #dfdfdf;
|
||||
background-color: #fdfdfd;
|
||||
background-image: linear-gradient(transparent 50%, rgba(69, 142, 209, 0.04) 50%);
|
||||
background-size: 3em 3em;
|
||||
background-origin: content-box;
|
||||
background-attachment: local;
|
||||
}
|
||||
|
||||
code[class*='language'] {
|
||||
max-height: inherit;
|
||||
height: inherit;
|
||||
padding: 0 1em;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Margin bottom to accomodate shadow */
|
||||
:not(pre) > code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
background-color: #fdfdfd;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre) > code[class*='language-'] {
|
||||
position: relative;
|
||||
padding: 0.2em;
|
||||
border-radius: 0.3em;
|
||||
color: #c92c2c;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: inline;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
pre[class*='language-']:before,
|
||||
pre[class*='language-']:after {
|
||||
content: '';
|
||||
z-index: -2;
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0.75em;
|
||||
left: 0.18em;
|
||||
width: 40%;
|
||||
height: 20%;
|
||||
max-height: 13em;
|
||||
box-shadow: 0px 13px 8px #979797;
|
||||
-webkit-transform: rotate(-2deg);
|
||||
-moz-transform: rotate(-2deg);
|
||||
-ms-transform: rotate(-2deg);
|
||||
-o-transform: rotate(-2deg);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
:not(pre) > code[class*='language-']:after,
|
||||
pre[class*='language-']:after {
|
||||
right: 0.75em;
|
||||
left: auto;
|
||||
-webkit-transform: rotate(2deg);
|
||||
-moz-transform: rotate(2deg);
|
||||
-ms-transform: rotate(2deg);
|
||||
-o-transform: rotate(2deg);
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.block-comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #7d8b99;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #5f6364;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.function-name,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #c92c2c;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.function,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #2f9c0a;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.token.variable {
|
||||
color: #a67f59;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword,
|
||||
.token.class-name {
|
||||
color: #1990b8;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important {
|
||||
color: #e90;
|
||||
}
|
||||
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #a67f59;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.token.important {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
pre[class*='language-']:before,
|
||||
pre[class*='language-']:after {
|
||||
bottom: 14px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Plugin styles */
|
||||
.token.tab:not(:empty):before,
|
||||
.token.cr:before,
|
||||
.token.lf:before {
|
||||
color: #e0d7d1;
|
||||
}
|
||||
|
||||
/* Plugin styles: Line Numbers */
|
||||
pre[class*='language-'].line-numbers {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
pre[class*='language-'].line-numbers code {
|
||||
padding-left: 3.8em;
|
||||
}
|
||||
|
||||
pre[class*='language-'].line-numbers .line-numbers-rows {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* Plugin styles: Line Highlight */
|
||||
pre[class*='language-'][data-line] {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
pre[data-line] code {
|
||||
position: relative;
|
||||
padding-left: 4em;
|
||||
}
|
||||
pre .line-highlight {
|
||||
margin-top: 0;
|
||||
}
|
@ -71,6 +71,7 @@ module.exports = merge(common, {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
plugins: [
|
||||
'syntax-dynamic-import',
|
||||
'react-hot-loader/babel',
|
||||
],
|
||||
},
|
||||
|
@ -36,7 +36,12 @@ module.exports = merge(common, {
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
{ loader: "awesome-typescript-loader" }
|
||||
{
|
||||
loader: 'awesome-typescript-loader',
|
||||
options: {
|
||||
errorsAsWarnings: false,
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
require('./sass.rule.js')({
|
||||
|
Loading…
Reference in New Issue
Block a user