mirror of
https://github.com/kubeshark/kubeshark.git
synced 2025-06-22 06:18:51 +00:00
Infinite scroll (#28)
* no message * infinite scroll + new ws implementation * no message * scrolling top * fetch button * more Backend changes * fix go mod and sum * mire fixes against develop * unused code * small ui refactor Co-authored-by: Roee Gadot <roee.gadot@up9.com>
This commit is contained in:
parent
b03134919e
commit
377fc79315
3
.gitignore
vendored
3
.gitignore
vendored
@ -17,8 +17,5 @@
|
||||
build
|
||||
*.db
|
||||
|
||||
# Build directories
|
||||
build
|
||||
|
||||
# Mac OS
|
||||
.DS_Store
|
||||
|
@ -38,4 +38,4 @@ COPY --from=site-build ["/ui-build/build", "site"]
|
||||
COPY api/start.sh .
|
||||
|
||||
# this script runs both apiserver and passivetapper and exits either if one of them exits, preventing a scenario where the container runs without one process
|
||||
CMD "./start.sh"
|
||||
CMD "./start.sh"
|
@ -2,26 +2,59 @@ package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/martian/har"
|
||||
"mizuserver/pkg/database"
|
||||
"mizuserver/pkg/models"
|
||||
"mizuserver/pkg/utils"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
HardLimit = 200
|
||||
)
|
||||
|
||||
func GetEntries(c *fiber.Ctx) error {
|
||||
limit, e := strconv.Atoi(c.Query("limit", "100"))
|
||||
limit, e := strconv.Atoi(c.Query("limit", "200"))
|
||||
utils.CheckErr(e)
|
||||
if limit > HardLimit {
|
||||
limit = HardLimit
|
||||
}
|
||||
|
||||
sortOption := c.Query("operator", "lt")
|
||||
var sortingOperator string
|
||||
var ordering string
|
||||
if strings.ToLower(sortOption) == "gt" {
|
||||
sortingOperator = ">"
|
||||
ordering = "asc"
|
||||
} else if strings.ToLower(sortOption) == "lt" {
|
||||
sortingOperator = "<"
|
||||
ordering = "desc"
|
||||
} else {
|
||||
fmt.Println("Unsupported")
|
||||
return nil
|
||||
}
|
||||
|
||||
timestamp, e := strconv.Atoi(c.Query("timestamp", "-1"))
|
||||
utils.CheckErr(e)
|
||||
|
||||
var entries []models.MizuEntry
|
||||
|
||||
database.GetEntriesTable().
|
||||
Order(fmt.Sprintf("timestamp %s", ordering)).
|
||||
Where(fmt.Sprintf("timestamp %s %v",sortingOperator, timestamp)).
|
||||
Omit("entry"). // remove the "big" entry field
|
||||
Limit(limit).
|
||||
Find(&entries)
|
||||
|
||||
if len(entries) > 0 && ordering == "desc"{
|
||||
utils.ReverseSlice(entries)
|
||||
}
|
||||
|
||||
// Convert to base entries
|
||||
baseEntries := make([]models.BaseEntryDetails, 0)
|
||||
baseEntries := make([]models.BaseEntryDetails, 0, limit)
|
||||
for _, entry := range entries {
|
||||
baseEntries = append(baseEntries, models.BaseEntryDetails{
|
||||
Id: entry.EntryId,
|
||||
@ -51,7 +84,6 @@ func GetEntry(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).JSON(fullEntry)
|
||||
}
|
||||
|
||||
|
||||
func DeleteAllEntries(c *fiber.Ctx) error {
|
||||
database.GetEntriesTable().
|
||||
Where("1 = 1").
|
||||
|
@ -76,7 +76,7 @@ func SaveHarToDb(entry har.Entry, source string) {
|
||||
Method: entry.Request.Method,
|
||||
Status: entry.Response.Status,
|
||||
Source: source,
|
||||
Timestamp: entry.StartedDateTime.Unix(),
|
||||
Timestamp: entry.StartedDateTime.UnixNano() / int64(time.Millisecond),
|
||||
}
|
||||
database.GetEntriesTable().Create(&mizuEntry)
|
||||
|
||||
@ -87,7 +87,7 @@ func SaveHarToDb(entry har.Entry, source string) {
|
||||
Path: urlPath,
|
||||
StatusCode: entry.Response.Status,
|
||||
Method: entry.Request.Method,
|
||||
Timestamp: entry.StartedDateTime.Unix(),
|
||||
Timestamp: entry.StartedDateTime.UnixNano() / int64(time.Millisecond),
|
||||
}
|
||||
baseEntryBytes, _ := json.Marshal(&baseEntry)
|
||||
ikisocket.Broadcast(baseEntryBytes)
|
||||
|
@ -13,5 +13,4 @@ func EntriesRoutes(fiberApp *fiber.App) {
|
||||
routeGroup.Get("/entries/:entryId", controllers.GetEntry) // get single (full) entry
|
||||
|
||||
routeGroup.Get("/resetDB", controllers.DeleteAllEntries) // get single (full) entry
|
||||
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"reflect"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
@ -29,6 +30,17 @@ func StartServer(app *fiber.App) {
|
||||
}
|
||||
}
|
||||
|
||||
func ReverseSlice(data interface{}) {
|
||||
value := reflect.ValueOf(data)
|
||||
valueLen := value.Len()
|
||||
for i := 0; i <= int((valueLen-1)/2); i++ {
|
||||
reverseIndex := valueLen - 1 - i
|
||||
tmp := value.Index(reverseIndex).Interface()
|
||||
value.Index(reverseIndex).Set(value.Index(i))
|
||||
value.Index(i).Set(reflect.ValueOf(tmp))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func CheckErr(e error) {
|
||||
if e != nil {
|
||||
|
5
ui/package-lock.json
generated
5
ui/package-lock.json
generated
@ -13448,11 +13448,6 @@
|
||||
"prop-types": "^15.6.2"
|
||||
}
|
||||
},
|
||||
"react-use-websocket": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-2.6.1.tgz",
|
||||
"integrity": "sha512-Nx1jUab+7eHpVftBpscgVG26UMVjy4P8ss+I3sE6LijHXC0chFej7unzH9YXElN9stEm1of+qZA+YX/ZlPIQBQ=="
|
||||
},
|
||||
"read-pkg": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz",
|
||||
|
@ -19,7 +19,6 @@
|
||||
"react-scripts": "4.0.3",
|
||||
"react-scrollable-feed": "^1.3.0",
|
||||
"react-syntax-highlighter": "^15.4.3",
|
||||
"react-use-websocket": "^2.6.1",
|
||||
"typescript": "^4.2.4",
|
||||
"web-vitals": "^1.1.1"
|
||||
},
|
||||
|
@ -1,28 +1,112 @@
|
||||
import {HarEntry} from "./HarEntry";
|
||||
import React, {useEffect, useRef} from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import styles from './style/HarEntriesList.module.sass';
|
||||
import ScrollableFeed from 'react-scrollable-feed'
|
||||
import spinner from './assets/spinner.svg';
|
||||
import ScrollableFeed from "react-scrollable-feed";
|
||||
|
||||
interface HarEntriesListProps {
|
||||
entries: any[];
|
||||
setEntries: (entries: any[]) => void;
|
||||
focusedEntryId: string;
|
||||
setFocusedEntryId: (id: string) => void
|
||||
setFocusedEntryId: (id: string) => void;
|
||||
connectionOpen: boolean;
|
||||
noMoreDataTop: boolean;
|
||||
setNoMoreDataTop: (flag: boolean) => void;
|
||||
noMoreDataBottom: boolean;
|
||||
setNoMoreDataBottom: (flag: boolean) => void;
|
||||
}
|
||||
|
||||
export const HarEntriesList: React.FC<HarEntriesListProps> = ({entries, focusedEntryId, setFocusedEntryId}) => {
|
||||
enum FetchOperator {
|
||||
LT = "lt",
|
||||
GT = "gt"
|
||||
}
|
||||
|
||||
export const HarEntriesList: React.FC<HarEntriesListProps> = ({entries, setEntries, focusedEntryId, setFocusedEntryId, connectionOpen, noMoreDataTop, setNoMoreDataTop, noMoreDataBottom, setNoMoreDataBottom}) => {
|
||||
|
||||
const [loadMoreTop, setLoadMoreTop] = useState(false);
|
||||
const [isLoadingTop, setIsLoadingTop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if(loadMoreTop && !connectionOpen && !noMoreDataTop)
|
||||
fetchData(FetchOperator.LT);
|
||||
}, [loadMoreTop, connectionOpen, noMoreDataTop]);
|
||||
|
||||
useEffect(() => {
|
||||
const list = document.getElementById('list').firstElementChild;
|
||||
list.addEventListener('scroll', (e) => {
|
||||
const el: any = e.target;
|
||||
if(el.scrollTop === 0) {
|
||||
setLoadMoreTop(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchData = async (operator) => {
|
||||
|
||||
const timestamp = operator === FetchOperator.LT ? entries[0].timestamp : entries[entries.length - 1].timestamp;
|
||||
if(operator === FetchOperator.LT)
|
||||
setIsLoadingTop(true);
|
||||
|
||||
fetch(`http://localhost:8899/api/entries?limit=50&operator=${operator}×tamp=${timestamp}`)
|
||||
.then(response => response.json())
|
||||
.then((data: any[]) => {
|
||||
let scrollTo;
|
||||
if(operator === FetchOperator.LT) {
|
||||
if(data.length === 0) {
|
||||
setNoMoreDataTop(true);
|
||||
scrollTo = document.getElementById("noMoreDataTop");
|
||||
} else {
|
||||
scrollTo = document.getElementById(entries[0].id);
|
||||
}
|
||||
const newEntries = [...data, ...entries];
|
||||
if(newEntries.length >= 1000) {
|
||||
newEntries.splice(1000);
|
||||
}
|
||||
setEntries(newEntries);
|
||||
setLoadMoreTop(false);
|
||||
setIsLoadingTop(false)
|
||||
if(scrollTo) {
|
||||
scrollTo.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
if(operator === FetchOperator.GT) {
|
||||
if(data.length === 0) {
|
||||
setNoMoreDataBottom(true);
|
||||
}
|
||||
scrollTo = document.getElementById(entries[entries.length -1].id);
|
||||
let newEntries = [...entries, ...data];
|
||||
if(newEntries.length >= 1000) {
|
||||
setNoMoreDataTop(false);
|
||||
newEntries = newEntries.slice(-1000);
|
||||
}
|
||||
setEntries(newEntries);
|
||||
if(scrollTo) {
|
||||
scrollTo.scrollIntoView({behavior: "smooth"});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return <>
|
||||
<div className={styles.list}>
|
||||
<ScrollableFeed>
|
||||
{entries?.map(entry => <HarEntry key={entry.id}
|
||||
<div id="list" className={styles.list}>
|
||||
{isLoadingTop && <div style={{display: "flex", justifyContent: "center", marginBottom: 10}}><img alt="spinner" src={spinner} style={{height: 25}}/></div>}
|
||||
<ScrollableFeed>
|
||||
{noMoreDataTop && !connectionOpen && <div id="noMoreDataTop" style={{textAlign: "center", fontWeight: 600, color: "rgba(255,255,255,0.75)"}}>No more data available</div>}
|
||||
{entries?.map(entry => <HarEntry key={entry.id}
|
||||
entry={entry}
|
||||
setFocusedEntryId={setFocusedEntryId}
|
||||
isSelected={focusedEntryId === entry.id}
|
||||
/>)}
|
||||
</ScrollableFeed>
|
||||
isSelected={focusedEntryId === entry.id}/>)}
|
||||
{!connectionOpen && !noMoreDataBottom && <div style={{width: "100%", display: "flex", justifyContent: "center", marginTop: 12, fontWeight: 600, color: "rgba(255,255,255,0.75)"}}>
|
||||
<div className={styles.styledButton} onClick={() => fetchData(FetchOperator.GT)}>Fetch more entries</div>
|
||||
</div>}
|
||||
</ScrollableFeed>
|
||||
</div>
|
||||
|
||||
{entries?.length > 0 && <div className={styles.footer}>
|
||||
<div><b>{entries?.length}</b> requests</div>
|
||||
<div>Started listening at <span style={{marginRight: 5, fontWeight: 600, fontSize: 13}}>{new Date(+entries[0].timestamp*1000)?.toLocaleString()}</span></div>
|
||||
<div>Started listening at <span style={{marginRight: 5, fontWeight: 600, fontSize: 13}}>{new Date(+entries[0].timestamp)?.toLocaleString()}</span></div>
|
||||
</div>}
|
||||
</div>
|
||||
</>;
|
||||
|
@ -23,7 +23,7 @@ interface HAREntryProps {
|
||||
export const HarEntry: React.FC<HAREntryProps> = ({entry, setFocusedEntryId, isSelected}) => {
|
||||
|
||||
return <>
|
||||
<div className={`${styles.row} ${isSelected ? styles.rowSelected : ''}`} onClick={() => setFocusedEntryId(entry.id)}>
|
||||
<div id={entry.id} className={`${styles.row} ${isSelected ? styles.rowSelected : ''}`} onClick={() => setFocusedEntryId(entry.id)}>
|
||||
{entry.statusCode && <div>
|
||||
<StatusCode statusCode={entry.statusCode}/>
|
||||
</div>}
|
||||
@ -33,7 +33,7 @@ export const HarEntry: React.FC<HAREntryProps> = ({entry, setFocusedEntryId, isS
|
||||
{entry.service}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.timestamp}>{new Date(+entry.timestamp*1000)?.toLocaleString()}</div>
|
||||
<div className={styles.timestamp}>{new Date(+entry.timestamp)?.toLocaleString()}</div>
|
||||
</div>
|
||||
</>
|
||||
};
|
@ -1,11 +1,12 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
// import {HarFilters} from "./HarFilters";
|
||||
import {HarEntriesList} from "./HarEntriesList";
|
||||
import {makeStyles} from "@material-ui/core";
|
||||
import "./style/HarPage.sass";
|
||||
import styles from './style/HarEntriesList.module.sass';
|
||||
import {HAREntryDetailed} from "./HarEntryDetailed";
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import playIcon from './assets/play.svg';
|
||||
import pauseIcon from './assets/pause.svg';
|
||||
|
||||
const useLayoutStyles = makeStyles(() => ({
|
||||
details: {
|
||||
@ -25,6 +26,12 @@ const useLayoutStyles = makeStyles(() => ({
|
||||
}
|
||||
}));
|
||||
|
||||
enum ConnectionStatus {
|
||||
Closed,
|
||||
Connected,
|
||||
Paused
|
||||
}
|
||||
|
||||
export const HarPage: React.FC = () => {
|
||||
|
||||
const classes = useLayoutStyles();
|
||||
@ -32,24 +39,41 @@ export const HarPage: React.FC = () => {
|
||||
const [entries, setEntries] = useState([] as any);
|
||||
const [focusedEntryId, setFocusedEntryId] = useState(null);
|
||||
const [selectedHarEntry, setSelectedHarEntry] = useState(null);
|
||||
const [connectionOpen, setConnectionOpen] = useState(false);
|
||||
const [connection, setConnection] = useState(ConnectionStatus.Closed);
|
||||
const [noMoreDataTop, setNoMoreDataTop] = useState(false);
|
||||
const [noMoreDataBottom, setNoMoreDataBottom] = useState(false);
|
||||
|
||||
const socketUrl = 'ws://localhost:8899/ws';
|
||||
const {lastMessage} = useWebSocket(socketUrl, {
|
||||
onOpen: () => setConnectionOpen(true),
|
||||
onClose: () => setConnectionOpen(false),
|
||||
shouldReconnect: (closeEvent) => true});
|
||||
const ws = useRef(null);
|
||||
|
||||
const openWebSocket = () => {
|
||||
ws.current = new WebSocket("ws://localhost:8899/ws");
|
||||
ws.current.onopen = () => setConnection(ConnectionStatus.Connected);
|
||||
ws.current.onclose = () => setConnection(ConnectionStatus.Closed);
|
||||
}
|
||||
|
||||
if(ws.current) {
|
||||
ws.current.onmessage = e => {
|
||||
console.log(connection);
|
||||
if(!e?.data) return;
|
||||
const entry = JSON.parse(e.data);
|
||||
if(connection === ConnectionStatus.Paused) {
|
||||
setNoMoreDataBottom(false)
|
||||
return;
|
||||
}
|
||||
if(!focusedEntryId) setFocusedEntryId(entry.id)
|
||||
let newEntries = [...entries];
|
||||
if(entries.length === 1000) {
|
||||
newEntries = newEntries.splice(1);
|
||||
setNoMoreDataTop(false);
|
||||
}
|
||||
setEntries([...newEntries, entry])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if(!lastMessage?.data) return;
|
||||
const entry = JSON.parse(lastMessage.data);
|
||||
if(!focusedEntryId) setFocusedEntryId(entry.id)
|
||||
let newEntries = [...entries];
|
||||
if(entries.length === 1000) {
|
||||
newEntries = newEntries.splice(1)
|
||||
}
|
||||
setEntries([...newEntries, entry])
|
||||
},[lastMessage?.data])
|
||||
openWebSocket();
|
||||
}, []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if(!focusedEntryId) return;
|
||||
@ -59,19 +83,43 @@ export const HarPage: React.FC = () => {
|
||||
.then(data => setSelectedHarEntry(data));
|
||||
},[focusedEntryId])
|
||||
|
||||
const toggleConnection = () => {
|
||||
setConnection(connection === ConnectionStatus.Connected ? ConnectionStatus.Paused : ConnectionStatus.Connected );
|
||||
}
|
||||
|
||||
const getConnectionStatusClass = () => {
|
||||
switch (connection) {
|
||||
case ConnectionStatus.Paused:
|
||||
return "orangeIndicator";
|
||||
case ConnectionStatus.Connected:
|
||||
return "greenIndicator"
|
||||
default:
|
||||
return "redIndicator";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="HarPage">
|
||||
<div style={{padding: "0 24px 24px 24px"}}>
|
||||
<div style={{padding: "0 24px 24px 24px", display: "flex", alignItems: "center"}}>
|
||||
<img style={{cursor: "pointer", marginRight: 15, height: 20}} alt="pause" src={connection === ConnectionStatus.Connected ? pauseIcon : playIcon} onClick={toggleConnection}/>
|
||||
<div className="connectionText">
|
||||
{connectionOpen ? "connected, waiting for traffic" : "not connected"}
|
||||
<div className={connectionOpen ? "greenIndicator" : "redIndicator"}/>
|
||||
{connection === ConnectionStatus.Connected ? "connected, waiting for traffic" : "not connected"}
|
||||
<div className={getConnectionStatusClass()}/>
|
||||
</div>
|
||||
</div>
|
||||
{entries.length > 0 && <div className="HarPage-Container">
|
||||
<div className="HarPage-ListContainer">
|
||||
{/*<HarFilters />*/}
|
||||
<div className={styles.container}>
|
||||
<HarEntriesList entries={entries} focusedEntryId={focusedEntryId} setFocusedEntryId={setFocusedEntryId}/>
|
||||
<HarEntriesList entries={entries}
|
||||
setEntries={setEntries}
|
||||
focusedEntryId={focusedEntryId}
|
||||
setFocusedEntryId={setFocusedEntryId}
|
||||
connectionOpen={connection === ConnectionStatus.Connected}
|
||||
noMoreDataBottom={noMoreDataBottom}
|
||||
setNoMoreDataBottom={setNoMoreDataBottom}
|
||||
noMoreDataTop={noMoreDataTop}
|
||||
setNoMoreDataTop={setNoMoreDataTop}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.details}>
|
||||
|
3
ui/src/components/assets/pause.svg
Normal file
3
ui/src/components/assets/pause.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 22 22" width="22" height="22"><defs><path d="M11 0C4.92 0 0 4.92 0 11C0 17.08 4.92 22 11 22C17.08 22 22 17.08 22 11C22 4.92 17.08 0 11 0Z" id="an9hysTx2"></path><path d="M11 1C5.48 1 1 5.48 1 11C1 16.52 5.48 21 11 21C16.52 21 21 16.52 21 11C21 5.48 16.52 1 11 1Z" id="hb6BxXbs"></path><path d="M8.27 7.03L9.98 7.03L9.98 14.97L8.27 14.97L8.27 7.03Z" id="g6umQPedLf"></path><path d="M11.64 7.03L13.35 7.03L13.35 14.97L11.64 14.97L11.64 7.03Z" id="c433t3qAh"></path></defs><g><g><g><use xlink:href="#an9hysTx2" opacity="1" fill="#000000" fill-opacity="0"></use><g><use xlink:href="#an9hysTx2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="2" stroke-opacity="0"></use></g></g><g><use xlink:href="#hb6BxXbs" opacity="1" fill="#000000" fill-opacity="0"></use><g><use xlink:href="#hb6BxXbs" opacity="1" fill-opacity="0" stroke="#4253a5" stroke-width="2" stroke-opacity="1"></use></g></g><g><use xlink:href="#g6umQPedLf" opacity="1" fill="#627ef7" fill-opacity="1"></use></g><g><use xlink:href="#c433t3qAh" opacity="1" fill="#627ef7" fill-opacity="1"></use></g></g></g></svg>
|
After Width: | Height: | Size: 1.3 KiB |
9
ui/src/components/assets/play.svg
Normal file
9
ui/src/components/assets/play.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="play" width="158" height="158" viewBox="0 0 158 158">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1{fill:#1b2036}.cls-2{fill:#fff}
|
||||
</style>
|
||||
</defs>
|
||||
<path id="Icon_material-play-circle-filled" d="M79.133 3a76.133 76.133 0 1 0 76.133 76.133A76.161 76.161 0 0 0 79.133 3zM63.906 113.393v-68.52l45.68 34.26z" class="cls-1" data-name="Icon material-play-circle-filled" transform="translate(.457 -.133)"/>
|
||||
<path id="Icon_material-play-circle-outline" d="M66.2 117.55L113.6 82 66.2 46.45zM82 3a79 79 0 1 0 79 79A79.029 79.029 0 0 0 82 3zm0 142.2A63.2 63.2 0 1 1 145.2 82 63.284 63.284 0 0 1 82 145.2z" class="cls-2" data-name="Icon material-play-circle-outline" transform="translate(-3 -3)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 757 B |
6
ui/src/components/assets/spinner.svg
Normal file
6
ui/src/components/assets/spinner.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: none; display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
|
||||
<circle cx="50" cy="50" fill="none" stroke="#1d3f72" stroke-width="10" r="35" stroke-dasharray="164.93361431346415 56.97787143782138" transform="rotate(275.903 50 50)">
|
||||
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"></animateTransform>
|
||||
</circle>
|
||||
<!-- [ldio] generated by https://loading.io/ --></svg>
|
After Width: | Height: | Size: 673 B |
@ -20,5 +20,19 @@
|
||||
border-top: 1px solid rgba(255,255,255,0.5)
|
||||
align-items: center
|
||||
padding-top: 10px
|
||||
margin-top: 10px
|
||||
margin-right: 15px
|
||||
margin-right: 15px
|
||||
|
||||
.styledButton
|
||||
cursor: pointer
|
||||
line-height: 1
|
||||
border-radius: 20px
|
||||
letter-spacing: .02857em
|
||||
color: #627ef7
|
||||
border: 1px solid rgba(98, 126, 247, 0.5)
|
||||
padding: 5px 18px
|
||||
transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms
|
||||
font-weight: 600
|
||||
|
||||
.styledButton:hover
|
||||
border: 1px solid #627ef7
|
||||
background-color: rgba(255, 255, 255, 0.06)
|
@ -87,6 +87,15 @@
|
||||
box-shadow: 0 0 3px 3px #ff3a3073
|
||||
margin-left: 10px
|
||||
|
||||
.orangeIndicator
|
||||
background-color: #ffb530
|
||||
height: 10px
|
||||
width: 10px
|
||||
border-radius: 50%
|
||||
box-shadow: 0 0 3px 3px #ffb33073
|
||||
margin-left: 10px
|
||||
|
||||
|
||||
.connectionText
|
||||
display: flex
|
||||
align-items: center
|
||||
|
Loading…
Reference in New Issue
Block a user