Procházet zdrojové kódy

Add ValidatorReport component.

Oleksandr Korniienko před 3 roky
rodič
revize
e24245e754

+ 2 - 0
.gitignore

@@ -12,6 +12,8 @@ upload_site.sh
 # testing
 /coverage
 
+.idea/
+
 # production
 /build
 

+ 5 - 0
package.json

@@ -4,6 +4,10 @@
   "license": "GPL-3.0-or-later",
   "repository": "https://github.com/Joystream/community-repo",
   "dependencies": {
+    "@material-ui/core": "^4.12.3",
+    "@material-ui/data-grid": "^0.1.67",
+    "@material-ui/icons": "^4.11.2",
+    "@material-ui/lab": "^4.0.0-alpha.60",
     "axios": "^0.21.1",
     "bootstrap": "^4.5.3",
     "d3-timeline": "^1.0.1",
@@ -21,6 +25,7 @@
     "react-router-dom": "^5.2.0",
     "react-scripts": "4.0.1",
     "remark-gfm": "^1.0.0",
+    "styled-components": "^5.3.0",
     "typescript": "^4.0.3",
     "video-react": "^0.14.1"
   },

+ 9 - 12
src/components/Dashboard/index.tsx

@@ -15,7 +15,6 @@ const Dashboard = (props: IProps) => {
   const {
     toggleStar,
     councils,
-    domain,
     handles,
     members,
     nominators,
@@ -35,20 +34,18 @@ const Dashboard = (props: IProps) => {
     <>
       <div className="w-100 flex-grow-1 d-flex align-items-center justify-content-center d-flex flex-column pb-5">
         <div className="back bg-warning d-flex flex-column p-2">
-          <Link to={`/calendar`}>Calendar</Link>
-	  <Link to={`/curation`}>Curation</Link>
-          <Link to={`/timeline`}>Timeline</Link>
-          <Link to={`/tokenomics`}>Reports</Link>
-          <Link to={`/validators`}>Validators</Link>
-          <Link to={`/storage`}>Storage</Link>
-          <Link to={`/spending`}>Spending</Link>
+          <Link to="/calendar">Calendar</Link>
+          <Link to="/curation">Curation</Link>
+          <Link to="/timeline">Timeline</Link>
+          <Link to="/tokenomics">Reports</Link>
+          <Link to="/validators">Validators</Link>
+          <Link to="/validator-report">Validator Report</Link>
+          <Link to="/storage">Storage</Link>
+          <Link to="/spending">Spending</Link>
+          <Link to="/transactions">Transfers</Link>
           <Link to="/mint">Toolbox</Link>
         </div>
 
-        <h1 className="title">
-          <a href={domain}>Joystream</a>
-        </h1>
-
         <Council
           councils={councils}
           members={members}

+ 5 - 0
src/components/Routes/index.tsx

@@ -19,6 +19,7 @@ import {
   Bounties,
 } from "..";
 import { IState } from "../../types";
+import ValidatorReport from '../ValidatorReport/ValidatorReport'
 
 interface IProps extends IState {
   toggleStar: (a: string) => void;
@@ -85,6 +86,10 @@ const Routes = (props: IProps) => {
         path="/validators"
         render={(routeprops) => <Validators {...routeprops} {...props} />}
       />
+      <Route
+        path="/validator-report"
+        render={(routeprops) => <ValidatorReport {...routeprops} {...props} />}
+      />
       <Route
         path="/storage"
         render={(routeprops) => <Storage {...routeprops} {...props} />}

+ 40 - 0
src/components/ValidatorReport/BootstrapButton.tsx

@@ -0,0 +1,40 @@
+import { Button, withStyles } from '@material-ui/core';
+
+export const BootstrapButton = withStyles({
+  root: {
+    boxShadow: 'none',
+    textTransform: 'none',
+    fontSize: 16,
+    padding: '6px 12px',
+    border: '1px solid',
+    lineHeight: 1.5,
+    color: '#ffffff',
+    backgroundColor: '#4138ff',
+    borderColor: '#4138ff',
+    fontFamily: [
+      '-apple-system',
+      'BlinkMacSystemFont',
+      '"Segoe UI"',
+      'Roboto',
+      '"Helvetica Neue"',
+      'Arial',
+      'sans-serif',
+      '"Apple Color Emoji"',
+      '"Segoe UI Emoji"',
+      '"Segoe UI Symbol"',
+    ].join(','),
+    '&:hover': {
+      backgroundColor: '#4138dd',
+      borderColor: '#4138dd',
+      boxShadow: 'none',
+    },
+    '&:active': {
+      boxShadow: 'none',
+      backgroundColor: '#4138ee',
+      borderColor: '#4138ee',
+    },
+    '&:focus': {
+      boxShadow: '0 0 0 0.2rem rgba(0,123,255,.5)',
+    },
+  },
+})(Button);

+ 35 - 0
src/components/ValidatorReport/Types.tsx

@@ -0,0 +1,35 @@
+export interface EraStatus {
+  status: ActiveEra;
+}
+
+export interface ActiveEra {
+  id: number,
+  era: number,
+  hash: string,
+  block: number,
+  date: string,
+  points: number
+}
+
+export interface Reports { 
+  pageSize: number,
+  totalCount: number,
+  totalBlocks: number,
+  startEra: number,
+  endEra: number,
+  startBlock: number,
+  endBlock: number,
+  startTime: number,
+  endTime: number,
+  report: Array<Report>
+};
+
+export interface Report { 
+  id: number,
+  stakeTotal: number,
+  stakeOwn: number,
+  points: number,
+  rewards: number,
+  commission: number,
+  blocksCount: number
+}

+ 255 - 0
src/components/ValidatorReport/ValidatorReport.tsx

@@ -0,0 +1,255 @@
+import { getChainState } from './get-status';
+import moment from 'moment'
+import { Card, CardActions, CardContent, CircularProgress, Container, createStyles, Grid, makeStyles, TextField, Typography } from '@material-ui/core';
+import Button from '@material-ui/core/Button';
+import { BootstrapButton } from './BootstrapButton';
+import Autocomplete from '@material-ui/lab/Autocomplete';
+import { useEffect, useState } from 'react';
+import axios from 'axios'
+import { config } from "dotenv";
+import { Report, Reports } from './Types';
+import { ColDef, DataGrid, PageChangeParams, ValueFormatterParams } from '@material-ui/data-grid';
+import Alert from '@material-ui/lab/Alert';
+import './index.css'
+
+config();
+
+const useStyles = makeStyles(() =>
+    createStyles({
+        root: {
+            flexGrow: 1,
+            backgroundColor: '#ffffff'
+        },
+    }),
+);
+
+
+const ValidatorReport = () => {
+    const dateFormat = 'yyyy-MM-DD';
+    const [activeValidators, setActiveValidators] = useState([]);
+    const [lastBlock, setLastBlock] = useState(0);
+    const [stash, setStash] = useState('5EhDdcWm4TdqKp1ew1PqtSpoAELmjbZZLm5E34aFoVYkXdRW');
+    const [dateFrom, setDateFrom] = useState(moment().subtract(14, 'd').format(dateFormat));
+    const [dateTo, setDateTo] = useState(moment().format(dateFormat));
+    const [startBlock, setStartBlock] = useState('' as unknown as number);
+    const [endBlock, setEndBlock] = useState('' as unknown as number);
+    const [isLoading, setIsLoading] = useState(false);
+    const [error, setError] = useState(undefined);
+    const [backendUrl] = useState("https://validators.joystreamstats.live");
+    const [columns] = useState(
+        [
+            { field: 'id', headerName: 'Era', width: 150, sortable: true },
+            { field: 'stakeTotal', headerName: 'Total Stake', width: 150, sortable: true },
+            { field: 'stakeOwn', headerName: 'Own Stake', width: 150, sortable: true },
+            { field: 'points', headerName: 'Points', width: 150, sortable: true },
+            { field: 'rewards', headerName: 'Rewards', width: 150, sortable: true },
+            { field: 'commission', headerName: 'Commission', width: 150, sortable: true, valueFormatter: (params: ValueFormatterParams) => {
+                if (isNaN(params.value as unknown as number)) {
+                    return `${params.value}%`
+                }
+                return `${Number(params.value).toFixed(0)}%`
+            }},
+            { field: 'blocksCount', headerName: 'Blocks Produced', width: 150, sortable: true },
+        ]
+    );
+    const [report, setReport] = useState({
+        pageSize: 0,
+        totalCount: 0,
+        totalBlocks: 0,
+        startEra: -1,
+        endEra: -1,
+        startBlock: -1,
+        endBlock: -1,
+        startTime: -1,
+        endTime: -1,
+        report: [] as unknown as Report[]
+    } as unknown as Reports );
+
+    useEffect(() => {
+        updateChainState()
+        const interval = setInterval(() => { updateChainState() }, 10000);
+        return () => clearInterval(interval);
+    }, []);
+
+    const updateChainState = () => {
+        getChainState().then((chainState) => {
+            setLastBlock(chainState.finalizedBlockHeight)
+            setActiveValidators(chainState.validators.validators)
+        })
+    }
+
+    const handlePageChange = (params: PageChangeParams) => {
+        if (report.totalCount > 0) {
+            loadReport(params.page)
+        }
+    }
+
+    const loadReport = (page: number) => {
+        setIsLoading(true)
+        const blockParam = startBlock && endBlock ? `&start_block=${startBlock}&end_block=${endBlock}` : ''
+        const dateParam = !(startBlock && endBlock) && dateFrom && dateTo ? `&start_time=${moment(dateFrom, dateFormat).format(dateFormat)}&end_time=${moment(dateTo, dateFormat).format(dateFormat)}` : ''
+        const apiUrl = `${backendUrl}/validator-report?addr=${stash}&page=${page}${blockParam}${dateParam}`
+        axios.get(apiUrl).then((response) => {
+            if (response.data.report !== undefined) {
+                setReport(response.data);
+            }
+            setIsLoading(false)
+            setError(undefined)
+        }).catch((err) => {
+            setIsLoading(false)
+            setError(err)
+        })
+    }
+
+    const stopLoadingReport = () => {
+        setIsLoading(false)
+    }
+
+    const canLoadReport = () => stash && ((startBlock && endBlock) || (dateFrom && dateTo))
+    const startOrStopLoading = () => isLoading ? stopLoadingReport() : loadReport(1);
+    const updateStartBlock = (e: { target: { value: unknown; }; }) => setStartBlock((e.target.value as unknown as number));
+    const updateEndblock = (e: { target: { value: unknown; }; }) => setEndBlock((e.target.value as unknown as number));
+    const updateDateFrom = (e: { target: { value: unknown; }; }) => setDateFrom((e.target.value as unknown as string))
+    const updateDateTo = (e: { target: { value: unknown; }; }) => setDateTo((e.target.value as unknown as string));
+
+    const getButtonTitle = (isLoading: boolean) => {
+        if (isLoading) {
+            return (<div style={{ display: 'flex', alignItems: 'center' }}>Stop loading <CircularProgress style={ { color: '#fff', height: 20, width: 20, marginLeft: 12 } } /></div>)
+        }
+        if (startBlock && endBlock) {
+            return `Load data between blocks ${startBlock} - ${endBlock}`
+        }
+        if (dateFrom && dateTo) {
+            return `Load data between dates ${dateFrom} - ${dateTo}`;
+        }
+        return 'Choose dates or blocks range'
+    }
+    const classes = useStyles();
+    return (
+        <div className={classes.root}>
+            <Container maxWidth="lg">
+                <Grid container spacing={2}>
+                    <Grid item lg={12}>
+                        <div style={{ display: 'flex', justifyContent: 'flex-start' }}>
+                            <h1>Validator Report</h1>
+                        </div>
+                    </Grid>
+                    <Grid item xs={12} lg={12}>
+                        <Autocomplete
+                            freeSolo
+                            style={{ width: '100%' }}
+                            options={activeValidators}
+                            onChange={(e, value) => setStash(value || '')}
+                            value={stash}
+                            renderInput={(params) => <TextField {...params} label="Validator stash address" variant="filled" />} />
+                    </Grid>
+                    <Grid item xs={6} lg={3}>
+                        <TextField fullWidth type="date" onChange={updateDateFrom} id="block-start" InputLabelProps={{ shrink: true }} label="Date From" value={dateFrom} variant="filled" />
+                    </Grid>
+                    <Grid item xs={6} lg={3}>
+                        <BootstrapButton size='large' style={{ minHeight: 56 }} fullWidth onClick={() => setDateFrom(moment().subtract(2, 'w').format('yyyy-MM-DD'))}>2 weeks from today</BootstrapButton>
+                    </Grid>
+                    <Grid item xs={6} lg={3}>
+                        <TextField fullWidth type="date" onChange={updateDateTo} id="block-end" InputLabelProps={{ shrink: true }} label="Date To" value={dateTo} variant="filled" />
+                    </Grid>
+                    <Grid item xs={6} lg={3}>
+                        <BootstrapButton size='large' style={{ minHeight: 56 }} fullWidth onClick={() => setDateTo(moment().format('yyyy-MM-DD'))}>Today</BootstrapButton>
+                    </Grid>
+                    <Grid item xs={6} lg={3}>
+                        <TextField fullWidth type="number" onChange={updateStartBlock} id="block-start" label="Start Block" value={startBlock} variant="filled" />
+                    </Grid>
+                    <Grid item xs={6} lg={3}>
+                        <BootstrapButton size='large' style={{ minHeight: 56 }} fullWidth disabled={!lastBlock} onClick={() => setStartBlock(lastBlock - (600 * 24 * 14))}>{lastBlock ? `2 weeks before latest (${lastBlock - (600 * 24 * 14)})` : '2 weeks from latest'}</BootstrapButton>
+                    </Grid>
+                    <Grid item xs={6} lg={3}>
+                        <TextField fullWidth type="number" onChange={updateEndblock} id="block-end" label="End Block" value={endBlock} variant="filled" />
+                    </Grid>
+                    <Grid item xs={6} lg={3}>
+                        <BootstrapButton size='large' style={{ minHeight: 56 }} fullWidth disabled={!lastBlock} onClick={() => setEndBlock(lastBlock)}>{lastBlock ? `Pick latest block (${lastBlock})` : 'Use latest block'}</BootstrapButton>
+                    </Grid>
+                    <Grid item xs={12} lg={12}>
+                        <BootstrapButton size='large' style={{ minHeight: 56 }} fullWidth disabled={!canLoadReport()} onClick={startOrStopLoading}>{getButtonTitle(isLoading)}</BootstrapButton>
+                        <Alert style={ error !== undefined ? { marginTop: 12 } : { display: 'none'} } onClose={() => setError(undefined)} severity="error">Error loading validator report, please try again.</Alert>
+                    </Grid>
+                    <Grid item xs={12} lg={12}>
+                        <ValidatorReportCard stash={stash} report={report} />
+                    </Grid>
+                    <Grid item xs={12} lg={12}>
+                        <div style={{ height: 600 }}>
+                            <DataGrid 
+                                rows={report.report} 
+                                columns={columns as unknown as ColDef[]}
+                                rowCount={report.totalCount}
+                                paginationMode="server"
+                                onPageChange={handlePageChange} 
+                                pageSize={report.pageSize}
+                                rowsPerPageOptions={[]}
+                                disableSelectionOnClick
+                                autoHeight
+                                />
+                        </div>
+                    </Grid>
+                </Grid>
+            </Container>
+        </div>
+    )
+}
+
+const ValidatorReportCard = (props: { stash: string, report: Reports }) => {
+    const copyValidatorStatistics = () => navigator.clipboard.writeText(scoringPeriodText)
+    const [scoringPeriodText, setScoringPeriodText] = useState('')
+    const useStyles = makeStyles({
+        root: {
+            minWidth: '100%',
+            textAlign: 'left',
+            color: '#343a40',
+        },
+        title: {
+            fontSize: 18,
+        },
+        pos: {
+            marginTop: 12,
+        },
+    });
+
+    const classes = useStyles();
+
+    useEffect(() => {
+        updateScoringPeriodText()
+    });
+
+    const updateScoringPeriodText = () => {
+        if (props.report.report.length > 0) {
+            const scoringDateFormat = 'DD-MM-yyyy';
+            const report = `Validator Date: ${moment(props.report.startTime).format(scoringDateFormat)} - ${moment(props.report.startTime).format(scoringDateFormat)}\nDescription: I was an active validator from era/block ${props.report.startEra}/${props.report.startBlock} to era/block ${props.report.endEra}/${props.report.endBlock}\nwith stash account ${props.stash}. (I was active in all the eras in this range and found a total of ${props.report.totalBlocks} blocks)`
+            setScoringPeriodText(report)
+        } else {
+            setScoringPeriodText('')
+        }
+    }
+
+    if (props.report.report.length > 0) {
+        return (<Card className={classes.root}>
+            <CardContent>
+                <Typography className={classes.title} color="textSecondary" gutterBottom>
+                    Validator Report:
+                </Typography>
+                { scoringPeriodText.split('\n').map((i, key) => <Typography key={key} className={classes.pos} color="textSecondary">{i}</Typography>) }
+            </CardContent>
+            <CardActions>
+                <Button onClick={copyValidatorStatistics} size="small">Copy to clipboard</Button>
+            </CardActions>
+        </Card>)
+    }
+    return (
+        <Card className={classes.root}>
+            <CardContent>
+                <Typography className={classes.pos} color="textSecondary">
+                    No Data Available
+                </Typography>
+            </CardContent>
+        </Card>
+    )
+}
+
+export default ValidatorReport

+ 11 - 0
src/components/ValidatorReport/debug.ts

@@ -0,0 +1,11 @@
+import moment from 'moment';
+
+moment.defaultFormat = 'YYYY-MM-DD HH:mm:ss';
+
+export const log = (...values: any[]) => {
+    // console.log(`[${moment().format()}]:`, ...values);
+}
+
+export const error = (...values: any[]) => {
+    console.error(`[${moment().format()}]:`, ...values);
+}

+ 28 - 0
src/components/ValidatorReport/get-status.ts

@@ -0,0 +1,28 @@
+import { log } from "./debug";
+import { JoyApi } from "./joyApi";
+import { EraStatus } from "./Types";
+import { PromiseAllObj } from "./utils";
+
+const api = new JoyApi();
+
+export async function getChainState() {
+  await api.init;
+
+  const status = await PromiseAllObj({
+    totalIssuance: await api.totalIssuance(),
+    finalizedBlockHeight: await api.finalizedBlockHeight(),
+    validators: await api.validatorsData(),
+    system: await api.systemData(),
+  });
+
+  log(status)
+  return status;
+}
+
+export async function getValidatorStatistics(address: string, blockStart: number): Promise<EraStatus> {
+  await api.init;
+  const status = await PromiseAllObj({
+    status: await api.getActiveErasForBlock(address, blockStart)
+  })
+  return status as unknown as EraStatus
+}

+ 5 - 0
src/components/ValidatorReport/index.css

@@ -0,0 +1,5 @@
+.footer {
+    position: relative;
+    background-color: white!important;
+    color: black!important;
+}

+ 129 - 0
src/components/ValidatorReport/joyApi.ts

@@ -0,0 +1,129 @@
+import { WsProvider, ApiPromise } from "@polkadot/api";
+import { types } from "@joystream/types";
+import { AccountId, Hash } from "@polkadot/types/interfaces";
+import { config } from "dotenv";
+import BN from "bn.js";
+import { Option, Vec } from "@polkadot/types";
+import { log } from "./debug"
+import { ActiveEra } from "./Types";
+
+config();
+
+export class JoyApi {
+  endpoint: string;
+  isReady: Promise<ApiPromise>;
+  api!: ApiPromise;
+
+  constructor(endpoint?: string) {
+    const wsEndpoint = endpoint || process.env.REACT_APP_WS_PROVIDER || "ws://127.0.0.1:9944";
+    this.endpoint = wsEndpoint;
+    this.isReady = (async () => {
+      const api = await new ApiPromise({ provider: new WsProvider(wsEndpoint), types })
+        .isReadyOrError;
+      return api;
+    })();
+  }
+  get init(): Promise<JoyApi> {
+    return this.isReady.then((instance) => {
+      this.api = instance;
+      return this;
+    });
+  }
+
+  async totalIssuance(blockHash?: Hash) {
+    const issuance =
+      blockHash === undefined
+        ? await this.api.query.balances.totalIssuance()
+        : await this.api.query.balances.totalIssuance.at(blockHash);
+
+    return issuance.toNumber();
+  }
+
+  async systemData() {
+    const [chain, nodeName, nodeVersion] = await Promise.all([
+      this.api.rpc.system.chain(),
+      this.api.rpc.system.name(),
+      this.api.rpc.system.version(),
+    ]);
+
+    return {
+      chain: chain.toString(),
+      nodeName: nodeName.toString(),
+      nodeVersion: nodeVersion.toString(),
+    };
+  }
+
+  async finalizedHash() {
+    return this.api.rpc.chain.getFinalizedHead();
+  }
+
+  async finalizedBlockHeight() {
+    const finalizedHash = await this.finalizedHash();
+    const { number } = await this.api.rpc.chain.getHeader(`${finalizedHash}`);
+    return number.toNumber();
+  }
+
+  async getActiveErasForBlock(address: string, blockStart: number): Promise<ActiveEra[] | undefined> {
+    const stash = address;
+    const startHash = (await this.api.rpc.chain.getBlockHash(blockStart));
+    const startEra = (await this.api.query.staking.activeEra.at(startHash)).unwrap().index.toNumber();
+    const startTimestamp = new Date((await this.api.query.timestamp.now.at(startHash)).toNumber()).toISOString();
+    const eraPoints = await this.api.query.staking.erasRewardPoints.at(startHash, startEra)
+    let data = undefined
+    eraPoints.individual.forEach((points, author) => {
+      log(`Author Points [${author}]`);
+      log(`Individual Points [${points}]`);
+      if (author.toString() === stash) {
+        const pn = Number(points.toBigInt())
+        const activeEra: ActiveEra = {
+          id: blockStart,
+          era: startEra,
+          hash: startHash.toString(),
+          block: blockStart,
+          date: startTimestamp,
+          points: pn
+        }
+        log(`Era [${activeEra.era}], Block [${activeEra.block}], Date [${activeEra.date}], Points [${activeEra.points}], Hash [${activeEra.hash}]`);
+        data = activeEra
+      }
+    });
+    return data
+  }
+
+  async findActiveValidators(hash: Hash, searchPreviousBlocks: boolean): Promise<AccountId[]> {
+    const block = await this.api.rpc.chain.getBlock(hash);
+
+    let currentBlockNr = block.block.header.number.toNumber();
+    let activeValidators;
+    do {
+      let currentHash = (await this.api.rpc.chain.getBlockHash(currentBlockNr)) as Hash;
+      let allValidators = await this.api.query.staking.snapshotValidators.at(currentHash) as Option<Vec<AccountId>>;
+      if (!allValidators.isEmpty) {
+        let max = (await this.api.query.staking.validatorCount.at(currentHash)).toNumber();
+        activeValidators = Array.from(allValidators.unwrap()).slice(0, max);
+      }
+
+      if (searchPreviousBlocks) {
+        --currentBlockNr;
+      } else {
+        ++currentBlockNr;
+      }
+
+    } while (activeValidators === undefined);
+    return activeValidators;
+  }
+
+  async validatorsData() {
+    const validators = await this.api.query.session.validators();
+    const era = await this.api.query.staking.currentEra();
+    const totalStake = era.isSome ?
+      await this.api.query.staking.erasTotalStake(era.unwrap())
+      : new BN(0);
+
+    return {
+      count: validators.length,
+      validators: validators.toJSON(),
+      total_stake: totalStake.toNumber(),
+    };
+  }
+}

+ 14 - 0
src/components/ValidatorReport/utils.ts

@@ -0,0 +1,14 @@
+const fromEntries = (xs: [string | number | symbol, any][]) =>
+  xs.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
+
+export function PromiseAllObj(obj: {
+  [k: string]: any;
+}): Promise<{ [k: string]: any }> {
+  return Promise.all(
+    Object.entries(obj).map(([key, val]) =>
+      val instanceof Promise
+        ? val.then((res) => [key, res])
+        : new Promise((res) => res([key, val]))
+    )
+  ).then((res: any[]) => fromEntries(res));
+}

+ 1 - 0
src/joystream.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240"><defs><style>.cls-1{fill:#4038ff;}.cls-2{fill:#fff;}</style></defs><title>Icon-mono-white-1bg-blue</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_14" data-name="Layer 14"><rect class="cls-1" width="240" height="240"/><path class="cls-2" d="M135.28,49.73l12.7,0-.15,59.67a57,57,0,0,1-14.49,37.86,67.76,67.76,0,0,0,1.72-15Z"/><path class="cls-2" d="M94.28,153.78v0a34.19,34.19,0,0,1-26.15,12.61L72,153.73Z"/><path class="cls-2" d="M102,130.94v1.28a34,34,0,0,1-2,11.41l-25-.06,3.83-12.69Z"/><path class="cls-2" d="M158.14,49.78l12.7,0-.09,36.84a57,57,0,0,1-14.49,37.86,67.76,67.76,0,0,0,1.72-15Z"/><path class="cls-2" d="M125.11,49.69l-.21,82.59a57.22,57.22,0,0,1-57.32,57H61.23l3.83-12.69h2.56a44.5,44.5,0,0,0,44.57-44.35l.22-82.58Z"/></g></g></svg>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 551 - 485
yarn.lock


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů