diff options
author | Emiliano Ciavatta | 2020-10-05 21:46:18 +0000 |
---|---|---|
committer | Emiliano Ciavatta | 2020-10-05 21:46:18 +0000 |
commit | e905618113309eaba7227ff1328a20f6846e4afd (patch) | |
tree | f6dd471683ac8ed7e630ce84956508ead28eab83 /frontend/src | |
parent | f11e5d9e55c963109af8b8517c7790bf2eb7cac8 (diff) |
Implement timeline
Diffstat (limited to 'frontend/src')
-rw-r--r-- | frontend/src/components/filters/FiltersDefinitions.js | 86 | ||||
-rw-r--r-- | frontend/src/components/panels/MainPane.js | 34 | ||||
-rw-r--r-- | frontend/src/globals.js | 5 | ||||
-rw-r--r-- | frontend/src/log.js | 7 | ||||
-rw-r--r-- | frontend/src/views/App.js | 18 | ||||
-rw-r--r-- | frontend/src/views/Connections.js | 158 | ||||
-rw-r--r-- | frontend/src/views/Connections.scss | 52 | ||||
-rw-r--r-- | frontend/src/views/Footer.js | 173 | ||||
-rw-r--r-- | frontend/src/views/Footer.scss | 17 |
9 files changed, 385 insertions, 165 deletions
diff --git a/frontend/src/components/filters/FiltersDefinitions.js b/frontend/src/components/filters/FiltersDefinitions.js index 02ccb42..d4f2912 100644 --- a/frontend/src/components/filters/FiltersDefinitions.js +++ b/frontend/src/components/filters/FiltersDefinitions.js @@ -1,12 +1,4 @@ -import { - cleanNumber, - timestampToTime, - timeToTimestamp, - validate24HourTime, - validateIpAddress, - validateMin, - validatePort -} from "../../utils"; +import {cleanNumber, validateIpAddress, validateMin, validatePort} from "../../utils"; import StringConnectionsFilter from "./StringConnectionsFilter"; import React from "react"; import RulesConnectionsFilter from "./RulesConnectionsFilter"; @@ -22,69 +14,69 @@ export const filtersDefinitions = { replaceFunc={cleanNumber} validateFunc={validatePort} key="service_port_filter" - width={200} />, - matched_rules: <RulesConnectionsFilter />, + width={200}/>, + matched_rules: <RulesConnectionsFilter/>, client_address: <StringConnectionsFilter filterName="client_address" defaultFilterValue="all_addresses" validateFunc={validateIpAddress} key="client_address_filter" - width={320} />, + width={320}/>, client_port: <StringConnectionsFilter filterName="client_port" defaultFilterValue="all_ports" replaceFunc={cleanNumber} validateFunc={validatePort} key="client_port_filter" - width={200} />, + width={200}/>, min_duration: <StringConnectionsFilter filterName="min_duration" defaultFilterValue="0" replaceFunc={cleanNumber} validateFunc={validateMin(0)} key="min_duration_filter" - width={200} />, + width={200}/>, max_duration: <StringConnectionsFilter filterName="max_duration" defaultFilterValue="∞" replaceFunc={cleanNumber} key="max_duration_filter" - width={200} />, + width={200}/>, min_bytes: <StringConnectionsFilter filterName="min_bytes" defaultFilterValue="0" replaceFunc={cleanNumber} validateFunc={validateMin(0)} key="min_bytes_filter" - width={200} />, + width={200}/>, max_bytes: <StringConnectionsFilter filterName="max_bytes" defaultFilterValue="∞" replaceFunc={cleanNumber} key="max_bytes_filter" - width={200} />, - started_after: <StringConnectionsFilter filterName="started_after" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="started_after_filter" - width={200} />, - started_before: <StringConnectionsFilter filterName="started_before" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="started_before_filter" - width={200} />, - closed_after: <StringConnectionsFilter filterName="closed_after" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="closed_after_filter" - width={200} />, - closed_before: <StringConnectionsFilter filterName="closed_before" - defaultFilterValue="00:00:00" - validateFunc={validate24HourTime} - encodeFunc={timeToTimestamp} - decodeFunc={timestampToTime} - key="closed_before_filter" - width={200} />, - marked: <BooleanConnectionsFilter filterName={"marked"} />, - hidden: <BooleanConnectionsFilter filterName={"hidden"} /> + width={200}/>, + // started_after: <StringConnectionsFilter filterName="started_after" + // defaultFilterValue="00:00:00" + // validateFunc={validate24HourTime} + // encodeFunc={timeToTimestamp} + // decodeFunc={timestampToTime} + // key="started_after_filter" + // width={230} />, + // started_before: <StringConnectionsFilter filterName="started_before" + // defaultFilterValue="00:00:00" + // validateFunc={validate24HourTime} + // encodeFunc={timeToTimestamp} + // decodeFunc={timestampToTime} + // key="started_before_filter" + // width={230} />, + // closed_after: <StringConnectionsFilter filterName="closed_after" + // defaultFilterValue="00:00:00" + // validateFunc={validate24HourTime} + // encodeFunc={timeToTimestamp} + // decodeFunc={timestampToTime} + // key="closed_after_filter" + // width={230} />, + // closed_before: <StringConnectionsFilter filterName="closed_before" + // defaultFilterValue="00:00:00" + // validateFunc={validate24HourTime} + // encodeFunc={timeToTimestamp} + // decodeFunc={timestampToTime} + // key="closed_before_filter" + // width={230} />, + marked: <BooleanConnectionsFilter filterName={"marked"}/>, + // hidden: <BooleanConnectionsFilter filterName={"hidden"} /> }; diff --git a/frontend/src/components/panels/MainPane.js b/frontend/src/components/panels/MainPane.js index 3202d6d..bd25e0c 100644 --- a/frontend/src/components/panels/MainPane.js +++ b/frontend/src/components/panels/MainPane.js @@ -8,24 +8,23 @@ import PcapPane from "./PcapPane"; import backend from "../../backend"; import RulePane from "./RulePane"; import ServicePane from "./ServicePane"; +import log from "../../log"; class MainPane extends Component { - constructor(props) { - super(props); - this.state = { - selectedConnection: null, - loading: false - }; - } + state = {}; componentDidMount() { const match = this.props.location.pathname.match(/^\/connections\/([a-f0-9]{24})$/); if (match != null) { - this.setState({loading: true}); + this.loading = true; backend.get(`/api/connections/${match[1]}`) - .then(res => this.setState({selectedConnection: res.json, loading: false})) - .catch(error => console.log(error)); + .then(res => { + this.loading = false; + this.setState({selectedConnection: res.json}); + log.debug(`Initial connection ${match[1]} loaded`); + }) + .catch(error => log.error("Error loading initial connection", error)); } } @@ -34,18 +33,19 @@ class MainPane extends Component { <div className="main-pane"> <div className="pane connections-pane"> { - !this.state.loading && + !this.loading && <Connections onSelected={(c) => this.setState({selectedConnection: c})} - initialConnection={this.state.selectedConnection} /> + initialConnection={this.state.selectedConnection}/> } </div> <div className="pane details-pane"> <Switch> - <Route path="/pcaps" children={<PcapPane />} /> - <Route path="/rules" children={<RulePane />} /> - <Route path="/services" children={<ServicePane />} /> - <Route exact path="/connections/:id" children={<ConnectionContent connection={this.state.selectedConnection} />} /> - <Route children={<ConnectionContent />} /> + <Route path="/pcaps" children={<PcapPane/>}/> + <Route path="/rules" children={<RulePane/>}/> + <Route path="/services" children={<ServicePane/>}/> + <Route exact path="/connections/:id" + children={<ConnectionContent connection={this.state.selectedConnection}/>}/> + <Route children={<ConnectionContent/>}/> </Switch> </div> </div> diff --git a/frontend/src/globals.js b/frontend/src/globals.js new file mode 100644 index 0000000..cd4dc64 --- /dev/null +++ b/frontend/src/globals.js @@ -0,0 +1,5 @@ +import {Dispatcher} from "flux"; + +const dispatcher = new Dispatcher(); + +export default dispatcher; diff --git a/frontend/src/log.js b/frontend/src/log.js new file mode 100644 index 0000000..0883962 --- /dev/null +++ b/frontend/src/log.js @@ -0,0 +1,7 @@ +const log = { + debug: (...obj) => console.info(...obj), + info: (...obj) => console.info(...obj), + error: (...obj) => console.error(obj) +}; + +export default log; diff --git a/frontend/src/views/App.js b/frontend/src/views/App.js index abd5209..00d9110 100644 --- a/frontend/src/views/App.js +++ b/frontend/src/views/App.js @@ -7,17 +7,17 @@ import {BrowserRouter as Router} from "react-router-dom"; import Filters from "./Filters"; import backend from "../backend"; import ConfigurationPane from "../components/panels/ConfigurationPane"; +import log from "../log"; class App extends Component { - constructor(props) { - super(props); - - this.state = {}; - } + state = {}; componentDidMount() { - backend.get("/api/services").then(_ => this.setState({configured: true})); + backend.get("/api/services").then(_ => { + log.debug("Caronte is already configured. Loading main.."); + this.setState({configured: true}); + }); setInterval(() => { if (document.title.endsWith("❚")) { @@ -38,11 +38,11 @@ class App extends Component { <div className="main"> <Router> <div className="main-header"> - <Header onOpenFilters={() => this.setState({filterWindowOpen: true})} /> + <Header onOpenFilters={() => this.setState({filterWindowOpen: true})}/> </div> <div className="main-content"> - {this.state.configured ? <MainPane /> : - <ConfigurationPane onConfigured={() => this.setState({configured: true})} />} + {this.state.configured ? <MainPane/> : + <ConfigurationPane onConfigured={() => this.setState({configured: true})}/>} {modal} </div> <div className="main-footer"> diff --git a/frontend/src/views/Connections.js b/frontend/src/views/Connections.js index 73979c4..fe655b3 100644 --- a/frontend/src/views/Connections.js +++ b/frontend/src/views/Connections.js @@ -6,26 +6,31 @@ import {Redirect} from 'react-router'; import {withRouter} from "react-router-dom"; import backend from "../backend"; import ConnectionMatchedRules from "../components/ConnectionMatchedRules"; +import dispatcher from "../globals"; +import log from "../log"; +import ButtonField from "../components/fields/ButtonField"; class Connections extends Component { + state = { + loading: false, + connections: [], + firstConnection: null, + lastConnection: null, + flagRule: null, + rules: null, + queryString: null + }; + constructor(props) { super(props); - this.state = { - loading: false, - connections: [], - firstConnection: null, - lastConnection: null, - prevParams: null, - flagRule: null, - rules: null, - queryString: null - }; this.scrollTopThreashold = 0.00001; this.scrollBottomThreashold = 0.99999; - this.maxConnections = 500; + this.maxConnections = 200; this.queryLimit = 50; + this.connectionsListRef = React.createRef(); + this.lastScrollPosition = 0; } componentDidMount() { @@ -35,6 +40,17 @@ class Connections extends Component { this.setState({selected: this.props.initialConnection.id}); // TODO: scroll to initial connection } + + dispatcher.register((payload) => { + if (payload.actionType === "timeline-update") { + this.connectionsListRef.current.scrollTop = 0; + this.loadConnections({ + started_after: Math.round(payload.from.getTime() / 1000), + started_before: Math.round(payload.to.getTime() / 1000), + limit: this.maxConnections + }).then(() => log.info(`Loading connections between ${payload.from} and ${payload.to}`)); + } + }); } connectionSelected = (c) => { @@ -46,7 +62,7 @@ class Connections extends Component { if (this.state.loaded && prevProps.location.search !== this.props.location.search) { this.setState({queryString: this.props.location.search}); this.loadConnections({limit: this.queryLimit}) - .then(() => console.log("Connections reloaded after query string update")); + .then(() => log.info("Connections reloaded after query string update")); } } @@ -54,12 +70,26 @@ class Connections extends Component { let relativeScroll = e.currentTarget.scrollTop / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight); if (!this.state.loading && relativeScroll > this.scrollBottomThreashold) { this.loadConnections({from: this.state.lastConnection.id, limit: this.queryLimit,}) - .then(() => console.log("Following connections loaded")); + .then(() => log.info("Following connections loaded")); } if (!this.state.loading && relativeScroll < this.scrollTopThreashold) { this.loadConnections({to: this.state.firstConnection.id, limit: this.queryLimit,}) - .then(() => console.log("Previous connections loaded")); + .then(() => log.info("Previous connections loaded")); + if (this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: false}); + } + } else { + if (this.lastScrollPosition > e.currentTarget.scrollTop) { + if (!this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: true}); + } + } else { + if (this.state.showMoreRecentButton) { + this.setState({showMoreRecentButton: false}); + } + } } + this.lastScrollPosition = e.currentTarget.scrollTop; }; addServicePortFilter = (port) => { @@ -85,14 +115,14 @@ class Connections extends Component { urlParams.set(name, value); } - this.setState({loading: true, prevParams: params}); + this.setState({loading: true}); let res = (await backend.get(`${url}?${urlParams}`)).json; let connections = this.state.connections; let firstConnection = this.state.firstConnection; let lastConnection = this.state.lastConnection; - if (params !== undefined && params.from !== undefined) { + if (params !== undefined && params.from !== undefined && params.to === undefined) { if (res.length > 0) { connections = this.state.connections.concat(res); lastConnection = connections[connections.length - 1]; @@ -102,7 +132,7 @@ class Connections extends Component { firstConnection = connections[0]; } } - } else if (params !== undefined && params.to !== undefined) { + } else if (params !== undefined && params.to !== undefined && params.from === undefined) { if (res.length > 0) { connections = res.concat(this.state.connections); firstConnection = connections[0]; @@ -112,6 +142,7 @@ class Connections extends Component { } } } else { + this.connectionsListRef.current.scrollTop = 0; if (res.length > 0) { connections = res; firstConnection = connections[0]; @@ -124,7 +155,7 @@ class Connections extends Component { } let rules = this.state.rules; - if (rules === null) { + if (rules == null) { rules = (await backend.get("/api/rules")).json; } @@ -135,15 +166,21 @@ class Connections extends Component { firstConnection: firstConnection, lastConnection: lastConnection }); + + if (firstConnection != null && lastConnection != null) { + dispatcher.dispatch({ + actionType: "connections-update", + from: new Date(lastConnection["started_at"]), + to: new Date(firstConnection["started_at"]) + }); + } } render() { let redirect; let queryString = this.state.queryString !== null ? this.state.queryString : ""; if (this.state.selected) { - let format = this.props.match.params.format; - format = format !== undefined ? "/" + format : ""; - redirect = <Redirect push to={`/connections/${this.state.selected}${format}${queryString}`} />; + redirect = <Redirect push to={`/connections/${this.state.selected}${queryString}`}/>; } let loading = null; @@ -154,48 +191,55 @@ class Connections extends Component { } return ( - <div className="connections" onScroll={this.handleScroll}> - <div className="connections-header-padding"/> - <Table borderless size="sm"> - <thead> - <tr> - <th>service</th> - <th>srcip</th> - <th>srcport</th> - <th>dstip</th> - <th>dstport</th> - <th>started_at</th> - <th>duration</th> - <th>up</th> - <th>down</th> - <th>actions</th> - </tr> - </thead> - <tbody> - { - this.state.connections.flatMap(c => { - return [<Connection key={c.id} data={c} onSelected={() => this.connectionSelected(c)} - selected={this.state.selected === c.id} - onMarked={marked => c.marked = marked} - onEnabled={enabled => c.hidden = !enabled} - addServicePortFilter={this.addServicePortFilter} />, - c.matched_rules.length > 0 && + <div className="connections-container"> + {this.state.showMoreRecentButton && <div className="most-recent-button"> + <ButtonField name="most_recent" variant="green" onClick={() => + this.loadConnections({limit: this.queryLimit}) + .then(() => log.info("Most recent connections loaded")) + }/> + </div>} + + <div className="connections" onScroll={this.handleScroll} ref={this.connectionsListRef}> + <Table borderless size="sm"> + <thead> + <tr> + <th>service</th> + <th>srcip</th> + <th>srcport</th> + <th>dstip</th> + <th>dstport</th> + <th>started_at</th> + <th>duration</th> + <th>up</th> + <th>down</th> + <th>actions</th> + </tr> + </thead> + <tbody> + { + this.state.connections.flatMap(c => { + return [<Connection key={c.id} data={c} onSelected={() => this.connectionSelected(c)} + selected={this.state.selected === c.id} + onMarked={marked => c.marked = marked} + onEnabled={enabled => c.hidden = !enabled} + addServicePortFilter={this.addServicePortFilter}/>, + c.matched_rules.length > 0 && <ConnectionMatchedRules key={c.id + "_m"} matchedRules={c.matched_rules} rules={this.state.rules} - addMatchedRulesFilter={this.addMatchedRulesFilter} /> - ]; - }) - } - {loading} - </tbody> - </Table> - - {redirect} + addMatchedRulesFilter={this.addMatchedRulesFilter}/> + ]; + }) + } + {loading} + </tbody> + </Table> + + {redirect} + </div> </div> ); } } - export default withRouter(Connections); diff --git a/frontend/src/views/Connections.scss b/frontend/src/views/Connections.scss index c7bd1df..9e2b6ba 100644 --- a/frontend/src/views/Connections.scss +++ b/frontend/src/views/Connections.scss @@ -1,37 +1,39 @@ @import "../colors.scss"; -.connections { +.connections-container { position: relative; - overflow: auto; height: 100%; - padding: 0 10px; + padding: 10px; background-color: $color-primary-3; - .table { - margin-top: 10px; - } + .connections { + position: relative; + overflow-y: scroll; + height: 100%; - .connections-header-padding { - position: sticky; - top: 0; - right: 0; - left: 0; - height: 10px; - margin-bottom: -10px; - background-color: $color-primary-3; - } + .table { + margin-bottom: 0; + } + + th { + font-size: 13.5px; + position: sticky; + top: 0; + padding: 5px; + border: none; + background-color: $color-primary-2; + } - th { - font-size: 13.5px; - position: sticky; - top: 10px; - padding: 5px; - border-top: 3px solid $color-primary-3; - border-bottom: 3px solid $color-primary-3; - background-color: $color-primary-2; + &:hover::-webkit-scrollbar-thumb { + background: $color-secondary-2; + } } - &:hover::-webkit-scrollbar-thumb { - background: $color-secondary-2; + .most-recent-button { + position: absolute; + z-index: 20; + top: 45px; + left: calc(50% - 50px); + background-color: red; } } diff --git a/frontend/src/views/Footer.js b/frontend/src/views/Footer.js index 0a3c5a3..ad1e4f7 100644 --- a/frontend/src/views/Footer.js +++ b/frontend/src/views/Footer.js @@ -1,19 +1,180 @@ import React, {Component} from 'react'; import './Footer.scss'; +import { + ChartContainer, + ChartRow, + Charts, + LineChart, + MultiBrush, + Resizable, + styler, + YAxis +} from "react-timeseries-charts"; +import {TimeRange, TimeSeries} from "pondjs"; +import backend from "../backend"; +import ChoiceField from "../components/fields/ChoiceField"; +import dispatcher from "../globals"; +import {withRouter} from "react-router-dom"; +import log from "../log"; + class Footer extends Component { + state = { + metric: "connections_per_service" + }; + + constructor() { + super(); + + this.disableTimeSeriesChanges = false; + this.selectionTimeout = null; + } + + filteredPort = () => { + const urlParams = new URLSearchParams(this.props.location.search); + return urlParams.get("service_port"); + }; + + componentDidMount() { + const filteredPort = this.filteredPort(); + this.setState({filteredPort}); + this.loadStatistics(this.state.metric, filteredPort).then(() => + log.debug("Statistics loaded after mount")); + + dispatcher.register((payload) => { + if (payload.actionType === "connections-update") { + this.setState({ + selection: new TimeRange(payload.from, payload.to), + }); + } + }); + } + + componentDidUpdate(prevProps, prevState, snapshot) { + const filteredPort = this.filteredPort(); + if (this.state.filteredPort !== filteredPort) { + this.setState({filteredPort}); + this.loadStatistics(this.state.metric, filteredPort).then(() => + log.debug("Statistics reloaded after filtered port changes")); + } + } + + loadStatistics = async (metric, filteredPort) => { + const urlParams = new URLSearchParams(); + urlParams.set("metric", metric); + + let services = (await backend.get("/api/services")).json; + if (filteredPort && services[filteredPort]) { + const service = services[filteredPort]; + services = {}; + services[filteredPort] = service; + } + + const ports = Object.keys(services); + ports.forEach(s => urlParams.append("ports", s)); + + const metrics = (await backend.get("/api/statistics?" + urlParams)).json; + const series = new TimeSeries({ + name: "statistics", + columns: ["time"].concat(ports), + points: metrics.map(m => [new Date(m["range_start"])].concat(ports.map(p => m[metric][p] || 0))) + }); + this.setState({ + metric, + series, + services, + timeRange: series.range(), + }); + log.debug(`Loaded statistics for metric "${metric}" for services [${ports}]`); + }; + + createStyler = () => { + return styler(Object.keys(this.state.services).map(port => { + return {key: port, color: this.state.services[port].color, width: 2}; + })); + }; + + handleTimeRangeChange = (timeRange) => { + if (!this.disableTimeSeriesChanges) { + this.setState({timeRange}); + } + }; + + handleSelectionChange = (timeRange) => { + this.disableTimeSeriesChanges = true; + + this.setState({selection: timeRange}); + if (this.selectionTimeout) { + clearTimeout(this.selectionTimeout); + } + this.selectionTimeout = setTimeout(() => { + dispatcher.dispatch({ + actionType: "timeline-update", + from: timeRange.begin(), + to: timeRange.end() + }); + this.selectionTimeout = null; + this.disableTimeSeriesChanges = false; + }, 1000); + }; + + aggregateSeries = (func) => { + const values = this.state.series.columns().map(c => this.state.series[func](c)); + return Math[func](...values); + }; + render() { return ( - <footer className="footer container-fluid"> - <div className="row"> - <div className="col-12"> - <div className="footer-timeline">timeline - <a href="https://github.com/eciavatta/caronte/issues/12">to be implemented</a></div> - </div> + <footer className="footer"> + <div className="time-line"> + {this.state.series && + <> + <Resizable> + <ChartContainer timeRange={this.state.timeRange} enableDragZoom={false} + paddingTop={5} utc minDuration={60000} + maxTime={this.state.series.range().end()} + minTime={this.state.series.range().begin()} + paddingLeft={0} paddingRight={0} paddingBottom={0} + enablePanZoom={true} + onTimeRangeChanged={this.handleTimeRangeChange}> + + <ChartRow height="125"> + <YAxis id="axis1" hideAxisLine + min={this.aggregateSeries("min")} + max={this.aggregateSeries("max")} width="35" type="linear" transition={300}/> + <Charts> + <LineChart axis="axis1" series={this.state.series} + columns={Object.keys(this.state.services)} + style={this.createStyler()} interpolation="curveBasis"/> + + <MultiBrush + timeRanges={[this.state.selection]} + allowSelectionClear={false} + allowFreeDrawing={false} + onTimeRangeChanged={this.handleSelectionChange} + /> + </Charts> + </ChartRow> + </ChartContainer> + </Resizable> + + <div className="metric-selection"> + <ChoiceField inline small + keys={["connections_per_service", "client_bytes_per_service", + "server_bytes_per_service", "duration_per_service"]} + values={["connections_per_service", "client_bytes_per_service", + "server_bytes_per_service", "duration_per_service"]} + onChange={(metric) => this.loadStatistics(metric, this.state.filteredPort) + .then(() => log.debug("Statistics loaded after metric changes"))} + value={this.state.metric}/> + </div> + </> + } </div> </footer> ); } } -export default Footer; +export default withRouter(Footer); diff --git a/frontend/src/views/Footer.scss b/frontend/src/views/Footer.scss index 6ec4a62..14360d4 100644 --- a/frontend/src/views/Footer.scss +++ b/frontend/src/views/Footer.scss @@ -1,13 +1,22 @@ @import "../colors.scss"; .footer { - padding: 15px 30px; + padding: 15px; - > .row { + .time-line { + position: relative; background-color: $color-primary-0; + + .metric-selection { + font-size: 0.8em; + position: absolute; + top: 5px; + right: 10px; + } } - .footer-timeline { - height: 100px; + svg text { + font-family: "Fira Code", monospace !important; + fill: $color-primary-4 !important; } } |