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:
lirazyehezkel 2021-05-04 18:23:58 +03:00 committed by GitHub
parent b03134919e
commit 377fc79315
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 258 additions and 51 deletions

3
.gitignore vendored
View File

@ -17,8 +17,5 @@
build
*.db
# Build directories
build
# Mac OS
.DS_Store

View File

@ -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").

View File

@ -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)

View File

@ -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
}

View File

@ -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
View File

@ -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",

View File

@ -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"
},

View File

@ -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}&timestamp=${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}>
<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}
/>)}
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>
</>;

View File

@ -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>
</>
};

View File

@ -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);
useEffect(() => {
if(!lastMessage?.data) return;
const entry = JSON.parse(lastMessage.data);
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)
newEntries = newEntries.splice(1);
setNoMoreDataTop(false);
}
setEntries([...newEntries, entry])
},[lastMessage?.data])
}
}
useEffect(() => {
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}>

View 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

View 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

View 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

View File

@ -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
.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)

View File

@ -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