Reupload
Some checks are pending
Run tests / build (6.0, 18.x) (push) Waiting to run

This commit is contained in:
Vyn 2025-06-11 09:50:52 +02:00
commit f265233a06
Signed by: vyn
GPG key ID: E1B2BE34E7A971E7
168 changed files with 31208 additions and 0 deletions

38
web-app/src/App.css Normal file
View file

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

9
web-app/src/App.test.tsx Normal file
View file

@ -0,0 +1,9 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

45
web-app/src/App.tsx Normal file
View file

@ -0,0 +1,45 @@
import React, {useState} from 'react';
import './App.css';
import {Api, useApi} from './api';
import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom';
import LoginPage from './pages/login';
import SignupPage from './pages/Signup';
import Projects from './pages/projects';
import {ModalServiceProvider, useModal} from './services/ModalService';
import ProjectPage from './pages/projectPage';
import HeaderBar from './components/HeaderBar';
import ProjectLogsPage from './pages/ProjectLogs';
import {ProjectApiKeysPage} from './pages/ProjectApiKeys';
import {RequireAuth} from './components/RequireAuth';
import ProjectPageBase from './components/ProjectPageBase';
function App() {
console.log(`App started on env '${process.env.NODE_ENV}'`)
console.log(`API url is ${process.env.REACT_APP_API_URL}`)
return (
<div className="App bg-neutral-700 text-white h-screen overflow-hidden f-c scroll-smooth">
<BrowserRouter>
<Api>
<ModalServiceProvider>
<div className="w-full h-full">
<Routes>
<Route path="/signup" element={<SignupPage/>}/>
<Route path="/" element={<LoginPage/>}/>
<Route path="/projects" element={
<RequireAuth>
<Projects/>
</RequireAuth>
}/>
<Route path="/projects/:projectId/*" element={
<ProjectPageBase/>
}/>
</Routes>
</div>
</ModalServiceProvider>
</Api>
</BrowserRouter>
</div>
);
}
export default App;

305
web-app/src/api/index.tsx Normal file
View file

@ -0,0 +1,305 @@
import React, {createContext, useContext} from "react";
import {ApiKey, ApiKeyData} from "../core/ApiKey";
import {Log, LogData} from "../core/Log";
import {Metric, MetricData, MetricDataOnUpdate} from "../core/Metric";
import {Project, ProjectData} from "../core/Project";
import { Tag, TagData } from "../core/Tag";
import {View, ViewDataOnCreation, ViewDataOnUpdate} from "../core/View";
import {DashboardView} from "../pages/projectPage/forms/OrganizeDashboardForm";
export interface UserData {
email: string,
token: string,
_id: string
}
type MyProps = {
children: any
}
type MyState = {
logged: boolean
loading: boolean
token: string | null
userData: UserData | null
}
export class Api extends React.Component<MyProps, MyState> {
private _baseUrl = process.env.REACT_APP_API_URL
state: MyState = {
logged: false,
loading: true,
token: localStorage.getItem("token"),
userData: null
}
/* eslint-disable-next-line */
constructor(props: MyProps) {
super(props)
}
public async componentDidMount() {
if (!this.state.token)
return;
try {
const userData = await this._FetchCurrentAuthentifiedUser()
if (userData && userData.email) {
console.log(`Connected with email ${userData.email}`)
this.setState({userData: userData, loading: false})
} else {
await this.Logout();
}
} catch (e) {
this.setState({userData: null, loading: false})
console.error("Something wrong happened")
console.error(e)
}
}
private async _Get(path: string) {
const response = await fetch(`${this._baseUrl}${path}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${this.state.token}`,
"Content-type": "application/json"
}
})
return response.json()
}
private async _Delete(path: string) {
await fetch(`${this._baseUrl}${path}`, {
method: "DELETE",
headers: {
"Authorization": `Bearer ${this.state.token}`,
"Content-type": "application/json"
}
})
}
private async _Post(path: string, body: Object) {
const response = await fetch(`${this._baseUrl}${path}`, {
method: "POST",
headers: {
"Authorization": `Bearer ${this.state.token}`,
"Content-type": "application/json"
},
body: JSON.stringify(body)
})
if (response.status < 200 || response.status > 299)
throw response
return response.json()
}
private async _Put(path: string, body: Object) {
const response = await fetch(`${this._baseUrl}${path}`, {
method: "PUT",
headers: {
"Authorization": `Bearer ${this.state.token}`,
"Content-type": "application/json"
},
body: JSON.stringify(body)
})
if (response.status < 200 || response.status > 299)
throw response
return response.json()
}
public async Login(email: string, password: string): Promise<UserData> {
const response = await fetch(`${this._baseUrl}/login`, {
method: "POST",
headers: {
"Content-type": "application/json"
},
body: JSON.stringify({
email,
password
})
})
const userData = (await response.json()) as unknown as UserData
if (response.status === 401) {
this.setState({logged: false, token: null, userData: null})
throw Error("Wrong email or password")
} else if (response.status !== 200) {
this.setState({logged: false, token: null, userData: null})
throw Error("Log in error")
}
localStorage.setItem("token", userData.token);
this.setState({logged: true, userData: userData, token: userData.token})
return userData
}
public async isUserRegistrationEnabled(): Promise<boolean> {
const response = await this._Get("/isUserRegistrationEnabled") as {enabled: boolean};
return response.enabled;
}
public async Signup(email: string, password: string, code: string): Promise<UserData> {
const response = await fetch(`${this._baseUrl}/signup`, {
method: "POST",
headers: {
"Content-type": "application/json"
},
body: JSON.stringify({
email,
password,
code
})
})
const userData = (await response.json()) as any
if (response.status !== 201) {
this.setState({logged: false, token: null, userData: null})
throw Error(userData?.message|| "Sign up error")
}
return userData
}
public async Logout(): Promise<void> {
this.setState({token: null, userData: null, loading: false})
localStorage.removeItem("token")
}
private async _FetchCurrentAuthentifiedUser(): Promise<UserData> {
return this._Get("/auth") as unknown as UserData
}
public async VerifyEmail(email: string, emailConfirmationToken: string): Promise<boolean> {
try {
await this._Get(`/signup-email-confirmation?email=${email}&emailConfirmationToken=${emailConfirmationToken}`);
return true;
} catch (e) {
return false;
}
}
public async fetchProjects(): Promise<Project[]> {
const projectsData = await this._Get("/projects") as ProjectData[];
return projectsData.map(data => new Project(data));
}
public async fetchProject(id: string): Promise<Project> {
const projectData = await this._Get(`/projects/${id}`) as ProjectData;
return new Project(projectData);
}
public async fetchProjectLogs(id: string): Promise<Log[]> {
const logsData = await this._Get(`/projects/${id}/logs`) as LogData[];
return logsData.map(data => new Log(data));
}
public async fetchProjectTags(id: string): Promise<Tag[]> {
const tagsData = await this._Get(`/projects/${id}/tags`) as TagData[];
return tagsData.map(data => new Tag(data));
}
public async deleteLog(projectId: string, logId: string): Promise<void> {
await this._Delete(`/projects/${projectId}/logs/${logId}`);
}
public async fetchProjectMetrics(id: string): Promise<Metric[]> {
const metricsData = await this._Get(`/projects/${id}/metrics`) as MetricData[];
return metricsData.map(data => new Metric(data));
}
public async fetchProjectMetric(projectId: string, metricId: string): Promise<Metric> {
const data = await this._Get(`/projects/${projectId}/metrics/${metricId}`) as MetricData;
return new Metric(data);
}
async createSeries(projectId: string, data: MetricDataOnUpdate): Promise<void> {
await this._Post(`/projects/${projectId}/metrics`, data)
}
async updateMetric(projectId: string, metricId: string, data: MetricDataOnUpdate): Promise<void> {
await this._Put(`/projects/${projectId}/metrics/${metricId}`, data)
}
async addValueToSerie(projectId: string, seriesId: string, value: number): Promise<void> {
await this._Post(`/projects/${projectId}/metrics/${seriesId}`, {value})
}
async deleteMetric(projectId: string, metricId: string): Promise<void> {
await this._Delete(`/projects/${projectId}/metrics/${metricId}`)
}
async deleteMetricHistory(projectId: string, metricId: string, recordId: string): Promise<void> {
await this._Delete(`/projects/${projectId}/metrics/${metricId}/history/${recordId}`)
}
async createView(projectId: string, data: ViewDataOnCreation<any>): Promise<void> {
await this._Post(`/projects/${projectId}/views`, {...data, config: JSON.stringify(data.config)})
}
async updateView(projectId: string, view: View<any>, data: ViewDataOnUpdate<any>): Promise<void> {
await this._Put(`/projects/${projectId}/views/${view.id}`, {...data, config: JSON.stringify(data.config)})
}
public async fetchDashboardView(projectId: string): Promise<View<DashboardView> | null> {
const viewsData = await this._Get(`/projects/${projectId}/views`) as View<DashboardView>[];
if (viewsData.length === 0) {
return null
}
return new View({...viewsData[0], config: JSON.parse(viewsData[0].config as unknown as string)});
}
public async fetchProjectApiKeys(id: string): Promise<ApiKey[]> {
const apiKeysData = await this._Get(`/projects/${id}/apiKeys`) as ApiKeyData[];
return apiKeysData.map(data => new ApiKey(data));
}
public async createProject(data: {name: string}): Promise<void> {
return this._Post("/projects", data);
}
public async generateApiKey(projectId: string, data: {name: string}): Promise<void> {
return this._Post(`/projects/${projectId}/apiKeys`, data);
}
public async deleteApiKey(projectId: string, apiKeyId: string): Promise<void> {
return this._Delete(`/projects/${projectId}/apiKeys/${apiKeyId}`);
}
public async createTag(projectId: string, data: {name: string, color: string}): Promise<void> {
return this._Post(`/projects/${projectId}/tags`, data);
}
public async updateTag(projectId: string, tagId: string, data: {name: string, color: string}): Promise<void> {
return this._Put(`/projects/${projectId}/tags/${tagId}`, data);
}
public async deleteTag(projectId: string, tagId: string): Promise<void> {
return this._Delete(`/projects/${projectId}/tags/${tagId}`);
}
public IsLogged(): boolean {
return this.state.token !== null
}
public GetUserData(): UserData {
return this.state.userData as UserData
}
public IsLoadingUser(): boolean {
return this.state.loading;
}
public render() {
return (
<ApiContext.Provider value={{api: this, logged: this.GetUserData() !== null}}>
{this.props.children}
</ApiContext.Provider>
)
}
}
export const ApiContext = createContext<{api: Api, logged: boolean}>({logged: false} as {api: Api, logged: boolean})
export const useApi = () => useContext(ApiContext).api

View file

@ -0,0 +1,3 @@
export default function AppName() {
return <span className="text-4xl"><span className="text-app-primary">Awary</span></span>
}

View file

@ -0,0 +1,41 @@
import {ButtonHTMLAttributes, useState} from "react";
import Spinner from "./Spinner";
const buttons: {[index: string]: string} = {
primary: `inline-block px-6 py-2.5 bg-primary text-white font-medium text-xs leading-tight uppercase rounded hover:bg-primary-hover focus:bg-primary focus:outline-none focus:ring-0 active:bg-primary-active transition duration-150 ease-in-out`,
danger: "inline-block px-6 py-2.5 bg-red-600 text-white font-medium text-xs leading-tight uppercase rounded hover:bg-red-700 focus:bg-red-700 focus:outline-none focus:ring-0 active:bg-red-800 transition duration-150 ease-in-out",
light: "inline-block px-6 py-2.5 bg-gray-200 text-gray-700 font-medium text-xs leading-tight uppercase rounded hover:bg-gray-300 focus:bg-gray-300 focus:outline-none focus:ring-0 active:bg-gray-400 transition duration-150 ease-in-out",
dark: "inline-block px-6 py-2.5 bg-neutral-800 text-white font-medium text-xs leading-tight uppercase rounded hover:bg-neutral-900 focus:bg-neutral-900 focus:outline-none focus:ring-0 active:bg-neutral-900 transition duration-150 ease-in-out",
"brighter-dark": "inline-block px-6 py-2.5 bg-neutral-700 text-white font-medium text-xs leading-tight uppercase rounded hover:bg-neutral-900 focus:bg-neutral-900 focus:outline-none focus:ring-0 active:bg-neutral-900 transition duration-150 ease-in-out",
}
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
text: string
color?: string
onClickAsync?: () => Promise<void>
spinner?: boolean
}
export default function Button({text, color, className, onClickAsync, spinner, ...props}: ButtonProps) {
const colorKey = color ? color : "primary"
const buttonStyle = buttons[colorKey] ? buttons[colorKey] : buttons.primary
const [showSpinner, SetShowSnipper] = useState(false)
let onClick = props.onClick
if (onClickAsync) {
onClick = async () => {
SetShowSnipper(true)
try {
await onClickAsync()
} catch (e) {console.error(e)}
SetShowSnipper(false)
}
}
const shouldShowSpinner = spinner || showSpinner
return (
<button {...props} onClick={onClick} className={`${buttonStyle} w-[fit-content] font-bold flex flex-row items-center gap-4 ${className}`} disabled={shouldShowSpinner}>{text} {shouldShowSpinner && <Spinner/>}</button>
)
}

View file

@ -0,0 +1,26 @@
import {HTMLAttributes, ReactElement} from "react";
interface CardProps extends HTMLAttributes<HTMLElement> {
header?: string | ReactElement<any, any>
hideSeparator?: boolean
className?: string
onClick?: () => void
}
export default function Card({header, hideSeparator, children, onClick, className}: CardProps) {
return (
<div
className={`bg-neutral-600 rounded-md text-left p-4 ${className}`}
onClick={() => {onClick?.()}}
>
{header && <>
<div className="text-xl bg-neutral-600">{header}</div>
{!hideSeparator && <div className="border-b border-b-neutral-500 my-4"/>}
</>}
<div>
{children}
</div>
</div>
)
}

View file

@ -0,0 +1,98 @@
import moment from "moment";
import { useState } from "react";
import {Bar, ComposedChart} from "recharts";
import {Line, ResponsiveContainer, Tooltip, XAxis, YAxis} from "recharts";
import {formatTimestamp} from "../utils/formatTimestamp";
import Select from "./Select";
const dayDuration = 1000 * 60 * 60 * 24 // Duration of a day in milliseconds
function lastDays(data: {value: number | null, date: number}[], alwaysUseLastValue: boolean, daysCount: number = 30) {
data.sort((a, b) => b.date - a.date)
const processedData: {date: string, value: number | null}[] = []
const startingPoint = moment(Date.now()).endOf("day").valueOf()
for (let currentDay = 0; currentDay < daysCount; ++currentDay) {
const beforeThreshold = startingPoint - (currentDay * dayDuration)
let last = data.find(x => x.date < beforeThreshold)
if (!alwaysUseLastValue && last && last?.date < beforeThreshold - (dayDuration)) {
last = undefined
}
processedData.push({
date: formatTimestamp(beforeThreshold),
value: last && last.value !== null ? last.value : null
})
}
return processedData.reverse()
}
type ChartProps = {
charts: {
name: string
type: string
color: string
alwaysUseLastValue: boolean
data: {
value: number
date: number
}[]
}[]
}
export default function Chart({charts}: ChartProps) {
const [daysRange, setDaysRange] = useState<number>(30)
const processedData = charts.map(d => ({
name: d.name,
type: d.type,
color: d.color,
data: lastDays(d.data, d.alwaysUseLastValue, daysRange)
}))
const chartData: {
values: number[]
date: string
}[] = []
for (let i = 0; i < processedData.length; ++i) {
for (let j = 0; j < daysRange; ++j) {
if (i === 0) {
chartData.push({date: processedData[i].data[j].date, values: [processedData[i].data[j].value || 0]})
continue;
}
chartData[j].values.push(processedData[i].data[j].value || 0)
}
}
return (
<>
<div className="mb-2">
<div className="f-r items-center gap-2">
<span>Show:</span>
<Select onChange={(event) => setDaysRange(parseInt(event.target.value))}>
<option value={7}>last 7 days</option>
<option value={15}>last 15 days</option>
<option selected value={30}>last 30 days</option>
<option value={60}>last 60 days</option>
<option value={90}>last 90 days</option>
<option value={360}>last 365 days</option>
</Select>
</div>
</div>
<div className="border-b border-b-neutral-500 my-4"/>
<ResponsiveContainer width="99%" height={180}>
<ComposedChart data={chartData}>
{chartData.map((_, index) => {
if (charts[index]?.type === "line")
return <Line type="monotone" dataKey={`values[${index}]`} dot={false} name={charts[index]?.name} stroke={charts[index].color} />
if (charts[index]?.type === "bar")
return <Bar fill={charts[index]?.color} dataKey={`values[${index}]`} name={charts[index]?.name} stroke={charts[index].color} />
})}
<XAxis dataKey="date" stroke="#AAAAAA"/>
<YAxis stroke="#AAAAAA"/>
<Tooltip contentStyle={{backgroundColor: "#444444"}}/>
</ComposedChart>
</ResponsiveContainer>
</>
)
}

View file

@ -0,0 +1,44 @@
import {useState} from "react";
import {Link, useNavigate} from "react-router-dom";
import {useApi} from "../api";
import AppName from "./AppName";
import {MenuList, MenuListItem} from "./MenuList";
export default function HeaderBar() {
const api = useApi();
const navigate = useNavigate()
const [showMenu, setShowMenu] = useState(false)
if (!api.IsLogged()) {
return null;
}
return (
<div className="bg-neutral-800 py-4 f-r justify-between items-center gap-2 px-0 xl:px-96">
<div className="f-r flex-1 justify-start">
<Link to={api.IsLogged() ? "/projects" : "/"}>
<AppName/>
</Link>
</div>
<div className="flex-1 f-r items-center justify-end">
{
api.GetUserData() ?
<>
<div className="p-1 px-2 border border-neutral-500 rounded-md relative">
<div className="cursor-pointer" onClick={() => { setShowMenu(!showMenu) }}>
{api.GetUserData().email}
</div>
<MenuList show={showMenu}>
<MenuListItem text="Logout" onClick={async () => {
await api.Logout();
navigate("/")
}}/>
</MenuList>
</div>
</>
: ""
}
</div>
</div>
)
}

View file

@ -0,0 +1,49 @@
import {InputHTMLAttributes, TextareaHTMLAttributes} from "react"
import {UseFormRegisterReturn} from "react-hook-form"
interface LineEditProps {
state?: string,
stateHandler?: (value: string) => void,
register?: UseFormRegisterReturn,
label?: string,
labelError?: string,
isValid?: (value: string) => boolean
value?: string | number
onChange?: (value: string) => void
required?: boolean
type?: string
minLength?: number
multiline?: boolean
className?: string
inputClassName?: string
rows?: number
cols?: number
}
export default function LineEdit({state, stateHandler, label, labelError, isValid, register, value, ...props}: LineEditProps) {
const inputProps: InputHTMLAttributes<HTMLInputElement> & TextareaHTMLAttributes<HTMLTextAreaElement> = {
className: `bg-neutral-700 text-white rounded-md p-2 flex-1 min-w-0 ${props.inputClassName}`,
onChange: ((e: any) => props.onChange && props.onChange(e.target.value)),
required: props.required,
type: props.type,
minLength: props.minLength,
rows: props.rows,
cols: props.cols
}
if (!register) {
inputProps.value = value
}
return (
<div className={`text-left min-w-0 ${props.className}`}>
{label && <div className="text-sm mb-1 ml-1">{label}</div>}
<div className="f-r">
{!props.multiline && <input {...inputProps} {...register}/>}
{props.multiline && <textarea {...inputProps} {...register}/>}
</div>
{labelError && <div className="text-red-300">{labelError}</div>}
</div>
)
}

View file

@ -0,0 +1,69 @@
import {TrashIcon} from "@heroicons/react/outline";
import moment from "moment";
import {useState} from "react";
import {useApi} from "../api";
import {Log} from "../core/Log";
import {Project} from "../core/Project";
import {TagSticker} from "./TagSticker";
import {useModal} from "../services/ModalService";
import {formatTimestamp} from "../utils/formatTimestamp";
export type LogCardProps = {
project: Project
log: Log
onDelete?: () => void
}
export function LogCard({log, project, onDelete}: LogCardProps) {
const [showContent, setShowContent] = useState(false);
const modalService = useModal()
const api = useApi()
const deleteLog = () => {
modalService.confirmation("Delete the log ?", async () => {
await api.deleteLog(log.projectId, log.id)
onDelete && onDelete();
})
}
return (
<div className="card p-4 cursor-pointer" onClick={() => {setShowContent(true)}}>
<div className="f-c gap-2" >
<div className="f-r gap-4 items-center">
<span className="text-xl">{log.title}</span>
<div className="f-r gap-2">
{log.tags.map(tag => <TagSticker tag={tag}/>)}
</div>
<span className="text-neutral-400 text-sm ml-auto">
{formatTimestamp(log.createdAt)}
</span>
<TrashIcon
className="w-4 h-4 text-red-300 cursor-pointer"
onClick={deleteLog}
/>
</div>
</div>
{showContent && <p className="text-sm text-left pt-4">{log.content}</p>}
</div>
)
}
export function TinyLogCard({log, project}: LogCardProps) {
let borderStyle = {}
if (log.tags.length > 0) {
borderStyle = {borderLeftColor: `${log.tags[0].color}`}
}
return (
<div className="f-c gap-2">
<div className="f-r items-stretch">
<div className="border-l-2 border-neutral-500 pr-2" style={borderStyle}/>
<p>{log.title}</p>
<p className='mt-auto ml-auto text-xs text-neutral-400'>{moment(log.createdAt).format("YYYY-MM-DD hh:mm a")}</p>
</div>
<div className="w-full border-b border-neutral-500"/>
</div>
)
}

View file

@ -0,0 +1,25 @@
import {HTMLAttributes} from "react";
export function MenuListItem(props: (HTMLAttributes<HTMLDivElement> & {text: string})) {
return (
<div
{...props}
className={`text-left p-4 hover:bg-neutral-700 cursor-pointer rounded-md ${props.className}`}
>
{props.text}
</div>
)
}
export function MenuList({show, ...props}: (HTMLAttributes<HTMLDivElement> & {show: boolean})) {
return (
<div
{...props}
className={`bg-neutral-900 absolute top-0 left-0 translate-y-[-110%] right-0 w-full rounded-md ${show ? "block" : "hidden"} ${props.className}`}
>
{props.children}
</div>
)
}

View file

@ -0,0 +1,42 @@
import {useEffect, useState} from "react"
interface ModalProps {
show: boolean
children?: JSX.Element | null
onClose: () => void
customId?: string
ignoreInputs?: boolean
}
export default function Modal({show, onClose, children, customId, ignoreInputs}: ModalProps) {
const [createdAt] = useState(Date.now())
useEffect(() => {
if (!show)
return;
function handleClose(e: any) {
if (!ignoreInputs && !document.getElementById(customId || 'modal-content')?.contains(e.target) && Date.now() - createdAt > 1000) {
onClose()
}
}
document.addEventListener('click', handleClose)
document.body.style.overflow = "hidden"
return () => {
document.removeEventListener('click', handleClose)
document.body.style.overflow = "visible"
}
}, [show, onClose, customId, createdAt])
if (!show) {
return <></>
}
return (
<div className="fixed top-0 left-0 w-screen h-screen bg-black bg-opacity-50 flex justify-center items-center overflow-scroll">
<div id={customId || 'modal-content'} className="bg-neutral-600 p-2 rounded-md max-h-[90%] overflow-scroll">
{children}
</div>
</div>
)
}

View file

@ -0,0 +1,30 @@
import { ClipboardCopyIcon } from "@heroicons/react/outline";
import { Project } from "../core/Project";
import { useModal } from "../services/ModalService";
import { copyToClipboard } from "../utils/copyToClipboard";
type ProjectHeaderProps = {
project: Project
middle?: JSX.Element
right?: JSX.Element
}
export default function ProjectHeader({project, middle, right}: ProjectHeaderProps) {
const modalService = useModal();
return (
<div className="f-r pb-4 border-b border-b-neutral-500">
<div className="flex-1 f-r items-center gap-2 text-left text-2xl">
<h2 className="text-3xl">{project.name}</h2>
<ClipboardCopyIcon
className="w-4 h-4 cursor-pointer"
onClick={() => {
copyToClipboard(project.id)
modalService.info("Project id copied to clipboard")
}}
/>
</div>
<div className="flex-1 f-c justify-center">{middle}</div>
<div className="flex-1 f-c justify-center">{right}</div>
</div>
)
}

View file

@ -0,0 +1,53 @@
import {useCallback, useEffect, useState} from "react";
import {Route, Routes, useParams} from "react-router-dom";
import {useApi} from "../api";
import {Project} from "../core/Project";
import { MetricHistoryPage } from "../pages/MetricHistory";
import {ProjectApiKeysPage} from "../pages/ProjectApiKeys";
import ProjectLogsPage from "../pages/ProjectLogs";
import {ProjectMetricsPage} from "../pages/ProjectMetrics";
import ProjectPage from "../pages/projectPage";
import {ProjectSideBar} from "./ProjectSideBar";
export default function ProjectPageBase() {
const api = useApi();
const {projectId} = useParams();
const [project, setProject] = useState<Project | null>(null);
const fetchProject = useCallback(async () => {
const project = await api.fetchProject(projectId as string);
setProject(project);
}, [projectId, api])
useEffect(() => {fetchProject()}, [fetchProject])
if (!project) {
return <div>Loading project...</div>
}
return (
<div className="flex-1 f-r h-full overflow-hidden">
<ProjectSideBar project={project}/>
<div className="flex-1 f-c gap-4 p-4 overflow-hidden">
<Routes>
<Route path="/" element={
<ProjectPage/>
}/>
<Route path="/logs" element={
<ProjectLogsPage/>
}/>
<Route path="/metrics" element={
<ProjectMetricsPage/>
}/>
<Route path="/metrics/:metricId/history" element={
<MetricHistoryPage/>
}/>
<Route path="/api-keys" element={
<ProjectApiKeysPage/>
}/>
</Routes>
</div>
</div>
)
}

View file

@ -0,0 +1,84 @@
import {useState} from "react";
import {Link, useNavigate} from "react-router-dom";
import {useApi} from "../api";
import AppName from "./AppName";
import {MenuList, MenuListItem} from "./MenuList";
import {BookOpenIcon, ChartBarIcon, CollectionIcon, KeyIcon, ViewGridIcon} from "@heroicons/react/outline"
import {Project} from "../core/Project";
const topSide = [
{
name: "Dashboard",
icon: ViewGridIcon,
route: "/"
},
{
name: "Logs",
icon: CollectionIcon,
route: "/logs"
},
{
name: "Metrics",
icon: ChartBarIcon,
route: "/metrics"
},
{
name: "Api keys",
icon: KeyIcon,
route: "/api-keys"
}
]
export type ProjectSideBarProps = {
project?: Project
}
export function ProjectSideBar({project}: ProjectSideBarProps) {
const api = useApi();
const navigate = useNavigate()
const [showMenu, setShowMenu] = useState(false)
if (!api.IsLogged()) {
return null;
}
return (
<div className="bg-neutral-800 p-4 flex flex-col justify-between items-center gap-2">
<div className="f-c flex-1 justify-start">
<Link to={api.IsLogged() ? "/projects" : "/"} className="mb-8">
<AppName/>
</Link>
{project && topSide.map(elem => (
<Link key={elem.route} to={`/projects/${project.id}${elem.route}`} className="f-r items-center gap-4 p-4 hover:bg-neutral-600 rounded-md duration-200">
<elem.icon className="w-8 h-8"/>
<div className="text-xl">{elem.name}</div>
</Link>)
)}
</div>
<div className="flex-1 f-c gap-4 items-center justify-end">
<a href={process.env.REACT_APP_DOCS_URL} target="_blank" className="f-r items-center gap-4 p-4 hover:bg-neutral-600 rounded-md duration-200">
<BookOpenIcon className="w-8 h-8"/>
<div className="text-xl">Docs</div>
</a>
{
api.GetUserData() ?
<>
<div className="p-1 px-2 border border-neutral-500 rounded-md relative">
<div className="cursor-pointer" onClick={() => { setShowMenu(!showMenu) }}>
{api.GetUserData().email}
</div>
<MenuList show={showMenu}>
{/*<MenuListItem text="Account settings" onClick={() => { console.log("Account settings") }}/>*/}
<MenuListItem text="Logout" onClick={async () => {
await api.Logout();
navigate("/")
}}/>
</MenuList>
</div>
</>
: ""
}
</div>
</div>
)
}

View file

@ -0,0 +1,20 @@
import {useState} from "react"
import {Navigate} from "react-router-dom"
import {useApi} from "../api"
import {useModal} from "../services/ModalService"
export function RequireAuth ({children}: {children: JSX.Element}) {
const api = useApi()
const modalService = useModal()
const [hasError, setHasError] = useState(false)
if (!api.IsLogged()) {
return <Navigate to="/"/>
}
if (!api.IsLoadingUser() && !api.GetUserData() && !hasError) {
setHasError(true)
modalService.error("Could not retrieve user data")
}
return children
}

View file

@ -0,0 +1,32 @@
import {SelectHTMLAttributes} from "react"
import {UseFormRegisterReturn} from "react-hook-form"
interface LineEditProps extends SelectHTMLAttributes<HTMLSelectElement> {
register?: UseFormRegisterReturn,
label?: string,
labelError?: string,
isValid?: (value: string) => boolean
children: JSX.Element | JSX.Element[]
}
export default function Select({children, label, labelError, isValid, register, ...props}: LineEditProps) {
let labelDiv = null
if (label) {
labelDiv = <div className="text-sm mb-1 ml-1">{label}</div>
}
let labelErrorDiv = null
if (labelError) {
labelErrorDiv = <div className="text-red-300">{labelError}</div>
}
return (
<div className="text-left">
{labelDiv}
<select {...props} {...register} className="bg-neutral-700 text-white rounded-md p-2">
{children}
</select>
{labelErrorDiv}
</div>
)
}

View file

@ -0,0 +1,60 @@
import {useState} from "react";
import {Link, useNavigate} from "react-router-dom";
import {useApi} from "../api";
import AppName from "./AppName";
import {MenuList, MenuListItem} from "./MenuList";
import {CollectionIcon} from "@heroicons/react/outline"
const topSide = [
{
name: "Projects",
icon: CollectionIcon,
route: "/projects"
}
]
export default function SideBar() {
const api = useApi();
const navigate = useNavigate()
const [showMenu, setShowMenu] = useState(false)
if (!api.IsLogged()) {
return null;
}
return (
<div className="bg-neutral-800 p-4 flex flex-col justify-between items-center gap-2">
<div className="f-c flex-1 justify-start">
<Link to={api.IsLogged() ? "/projects" : "/"} className="mb-8">
<AppName/>
</Link>
{topSide.map(elem => (
<Link key={elem.route} to={elem.route} className="f-r items-center gap-4 p-4 hover:bg-neutral-600 rounded-md duration-200">
<elem.icon className="w-8 h-8"/>
<div className="text-xl">{elem.name}</div>
</Link>)
)}
</div>
<div className="flex-1 flex flex-col items-center justify-end">
{
api.GetUserData() ?
<>
<div className="p-1 px-2 border border-neutral-500 rounded-md relative">
<div className="cursor-pointer" onClick={() => { setShowMenu(!showMenu) }}>
{api.GetUserData().email}
</div>
<MenuList show={showMenu}>
{/*<MenuListItem text="Account settings" onClick={() => { console.log("Account settings") }}/>*/}
<MenuListItem text="Logout" onClick={async () => {
await api.Logout();
navigate("/")
}}/>
</MenuList>
</div>
</>
: ""
}
</div>
</div>
)
}

View file

@ -0,0 +1,35 @@
import {useState} from "react"
interface SideMenuProps {
items: {
label: string
}[]
onChange: (index: number) => void
}
export function SideMenu({items, onChange}: SideMenuProps) {
const [selectedIndex, setSelectedIndex] = useState(0)
return (
<div>
<div className="f-c p-2 gap-2 bg-neutral-600 rounded-md text-left cursor-pointer">
{items.map((item, index) => {
return (
<div
key={index}
className={index === selectedIndex
? "bg-neutral-500 p-1 px-2 rounded-md whitespace-nowrap"
: "p-1 px-2 hover:bg-neutral-500 duration-100 rounded-md whitespace-nowrap"}
onClick={() => {
setSelectedIndex(index)
onChange(index)
}}>{item.label}
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,13 @@
export interface SpinnerProps {
size?: number
}
export default function Spinner({size = 3}: SpinnerProps) {
return (
<div className="flex justify-center items-center">
<div className="spinner-border animate-spin inline-block border-2 rounded-full" role="status" style={{width: size, height: size}}>
<span className="visually-hidden">Loading...</span>
</div>
</div>
)
}

View file

@ -0,0 +1,37 @@
import {useState} from "react"
interface TabsProps {
tabs: {
name: string,
renderer: JSX.Element
}[]
}
export default function Tabs({tabs}: TabsProps) {
const [selectedTab, SetSelectedTab] = useState(0)
const selectedButtonClassName = "border-b border-b-app-primary text-awary-primary box-border p-4 cursor-pointer"
const unselectedButtonClassName = "border-b border-neutral-600 box-border p-4 cursor-pointer"
const bar = tabs.map((tab, index) =>
<div
key={index}
className={index === selectedTab ? selectedButtonClassName : unselectedButtonClassName}
onClick={() => {SetSelectedTab(index)}}
>
{tab.name}
</div>
)
return (
<>
<div className="flex flex-row border-b border-b-neutral-500">
{bar}
</div>
<div>
{tabs[selectedTab].renderer}
</div>
</>
)
}

View file

@ -0,0 +1,12 @@
import { TagData } from "../core/Tag"
export function TagSticker({tag}: {tag: TagData}) {
return (
<div
className="border border-neutral-400 rounded-md text-xs py-1 px-4"
style={{color: tag.color, borderColor: tag.color}}
>
{tag.name}
</div>
)
}

View file

@ -0,0 +1,45 @@
import {UseFormRegisterReturn} from "react-hook-form"
interface URLEditProps {
state?: string,
stateHandler?: (value: string) => void,
registerMethod?: UseFormRegisterReturn,
registerUrl?: UseFormRegisterReturn,
label?: string,
labelError?: string,
isValid?: (value: string) => boolean
value?: string
onChange?: (value: string) => void
required?: boolean
type?: string
minLength?: number
className?: string
inputClassName?: string
}
export default function URLEdit({state, stateHandler, label, labelError, isValid, registerMethod, registerUrl, value, ...props}: URLEditProps) {
const inputProps = {
className: `bg-neutral-700 text-white rounded-r-md p-2 flex-1 min-w-0 ${props.inputClassName}`,
onChange: ((e: any) => props.onChange && props.onChange(e.target.value)),
required: props.required,
type: props.type,
minLength: props.minLength,
}
return (
<div className={`text-left min-w-0 ${props.className}`}>
{label && <div className="text-sm mb-1 ml-1">{label}</div>}
<div className="f-r">
<select {...registerMethod} className="bg-neutral-700 text-white rounded-l-md p-2 border-r border-r-neutral-500">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
</select>
<input {...inputProps} {...registerUrl}/>
</div>
{labelError && <div className="text-red-300">{labelError}</div>}
</div>
)
}

View file

@ -0,0 +1,15 @@
export interface ApiKeyData {
id: string,
key: string,
name: string
}
export interface ApiKey extends Readonly<ApiKeyData> {}
export class ApiKey {
constructor(data: ApiKeyData) {
Object.assign(this, data);
}
}

23
web-app/src/core/Log.ts Normal file
View file

@ -0,0 +1,23 @@
export interface LogData {
id: string,
title: string,
content: string,
tags: {
id: string
name: string
color: string
}[]
projectId: string
createdAt: number
createdBy: string
}
export interface Log extends Readonly<LogData> {}
export class Log {
constructor(data: LogData) {
Object.assign(this, data);
}
}

View file

@ -0,0 +1,34 @@
export interface MetricDataOnCreation {
projectId: string
name: string
type: string
}
export interface MetricDataOnUpdate {
name: string
type: string
}
export interface MetricData {
id: string
projectId: string
name: string
color: string
history: {
id: string
seriesId: string
date: number
value: number
}[] | null
currentValue?: number
}
export interface Metric extends Readonly<MetricData> {}
export class Metric {
constructor(data: MetricData) {
Object.assign(this, data);
}
}

View file

@ -0,0 +1,15 @@
import {TagData} from "./Tag";
export interface ProjectData {
id: string,
name: string
tags: TagData[]
}
export interface Project extends Readonly<ProjectData> {}
export class Project {
constructor(data: ProjectData) {
Object.assign(this, data);
}
}

14
web-app/src/core/Tag.ts Normal file
View file

@ -0,0 +1,14 @@
export interface TagData {
id?: string
name: string
color: string
}
export interface Tag extends Readonly<TagData> {}
export class Tag {
constructor(data: TagData) {
Object.assign(this, data);
}
}

30
web-app/src/core/View.ts Normal file
View file

@ -0,0 +1,30 @@
export interface ViewDataOnCreation<T> {
name: string
type: string
provider: string
config: T
}
export interface ViewDataOnUpdate<T> {
name: string
config: T
}
export interface ViewData<T> {
id: string,
projectId: string,
type: string
name: string
provider: string
config: T
}
export interface View<T> extends Readonly<ViewData<T>> {}
export class View<T> {
constructor(data: ViewData<T>) {
Object.assign(this, data);
}
}

View file

@ -0,0 +1,32 @@
import {SubmitHandler, useForm} from "react-hook-form"
import Button from "../components/Button";
import LineEdit from "../components/Input";
import {Metric, MetricDataOnUpdate} from "../core/Metric";
interface CreateProjectFormProps {
metric?: Metric // If provided, we're on an update, otherwise it's a creation.
close: any
onCreate: (data: MetricDataOnUpdate) => void
}
export function MetricForm({metric, close, onCreate}: CreateProjectFormProps) {
const {register, handleSubmit} = useForm<MetricDataOnUpdate>({
defaultValues: {
name: metric?.name,
type: "numeric"
}
});
const createMetric: SubmitHandler<MetricDataOnUpdate> = async (data) => {
onCreate(data);
close();
}
return (
<form onSubmit={handleSubmit(createMetric)} className="f-c gap-2">
<h2 className="text-2xl mb-4 text-left">{metric ? "Update" : "Create"} metric</h2>
<LineEdit label="Name" register={register("name")}/>
<Button type="submit" text={metric ? "Update" : "Create"} className="ml-auto"/>
</form>
)
}

View file

@ -0,0 +1,26 @@
import {SubmitHandler, useForm} from "react-hook-form"
import Button from "../components/Button";
import LineEdit from "../components/Input";
import {ProjectData} from "../core/Project"
interface CreateProjectFormProps {
close: any
onCreate: any
}
export function CreateProjectForm(props: CreateProjectFormProps) {
const {register, handleSubmit} = useForm<Omit<ProjectData, "id">>();
const createProject: SubmitHandler<Omit<ProjectData, "id">> = async (data) => {
props.onCreate(data);
props.close();
}
return (
<form onSubmit={handleSubmit(createProject)} className="f-c gap-2">
<h2 className="text-2xl mb-4 text-left">Create a new project</h2>
<LineEdit label="Name" register={register("name")}/>
<Button type="submit" text="Create"/>
</form>
)
}

35
web-app/src/index.css Normal file
View file

@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
.f-r {
display: flex;
flex-direction: row;
}
.f-c {
display: flex;
flex-direction: column;
}
@layer components {
.card {
color: theme('colors.white');
background-color: theme('colors.neutral.600');
border-radius: theme('borderRadius.md');
}
}

19
web-app/src/index.tsx Normal file
View file

@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
web-app/src/logo.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,60 @@
import {CheckIcon} from "@heroicons/react/outline";
import {useEffect, useState} from "react";
import {useNavigate, useSearchParams} from "react-router-dom";
import {useApi} from "../api";
import Button from "../components/Button";
import Spinner from "../components/Spinner";
export default function EmailConfirmation() {
const [validating, setValidating] = useState(false)
const [error, setError] = useState(false)
const api = useApi();
const navigate = useNavigate()
const [searchParams] = useSearchParams();
const email = searchParams.get("email")
const emailConfirmationToken = searchParams.get("emailConfirmationToken")
useEffect(() => {
if (!email || !emailConfirmationToken) {
navigate("/")
return;
}
setValidating(true)
api.VerifyEmail(email, emailConfirmationToken).then(success => {
setValidating(false)
setError(success)
}).catch(() => {
setValidating(false)
setError(true)
})
}, [api, navigate, setValidating, setError, email, emailConfirmationToken])
return (
<div className="p-4 f-c gap-4 text-left">
{email}
<div className="f-r gap-4 items-center">
{validating &&
<>
<Spinner size={16}/>
Verifying your email
</>
}
{!validating && !error &&
<>
<CheckIcon className="w-8 h-8 text-green-300"/>
Email verified!
</>
}
{!validating && error &&
<>
<CheckIcon className="w-8 h-8 text-red-300"/>
<p>An error occured or you may have already verified your email.<br/>
Contact us if the problem persists</p>
</>
}
</div>
{!validating && <Button text="Go to login page" onClick={() => {navigate('/')}}/>}
</div>
)
}

View file

@ -0,0 +1,73 @@
import {TrashIcon} from "@heroicons/react/outline";
import {useCallback, useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import {useApi} from "../api";
import Button from "../components/Button";
import {Metric} from "../core/Metric";
import {Project} from "../core/Project";
import {MetricForm} from "../forms/createMetricForm";
import {useModal} from "../services/ModalService";
import {formatTimestamp} from "../utils/formatTimestamp";
export function MetricHistoryPage() {
const api = useApi();
const modalService = useModal();
const {projectId, metricId} = useParams();
const [project, setProject] = useState<Project | null>(null);
const [metric, setMetric] = useState<Metric | null>(null);
const fetchProject = useCallback(async () => {
const project = await api.fetchProject(projectId as string);
setProject(project);
const metric = await api.fetchProjectMetric(projectId as string, metricId as string);
setMetric(metric);
}, [projectId, metricId, api])
useEffect(() => {fetchProject()}, [fetchProject])
const onSomethingChanged = () => {
fetchProject();
}
if (!project || !metric) {
return <div>Loading project...</div>
}
const metricForm = (close: () => void) =>
<MetricForm
close={close}
onCreate={async (data) => {
close()
await api.createSeries(project.id, data)
onSomethingChanged()
}}
/>
const deleteRecord = async (recordId: string) => {
await api.deleteMetricHistory(metric.projectId, metric.id, recordId);
fetchProject();
}
return (
<div className="flex-1 f-c gap-4 overflow-scroll">
<div className="f-r justify-between items-center border-b border-neutral-600">
<h2 className="text-left text-3xl font-bold pb-4">Metrics</h2>
<Button text="Create new metric" onClick={() => modalService.addModal(metricForm)}/>
</div>
<div className="f-c gap-2 overflow-scroll pr-2">
{metric?.history?.map(record =>
<div className="card f-r gap-4 p-2">
<div>Value: {record.value}</div>
<div>Date: {formatTimestamp(record.date)}</div>
<TrashIcon
className="ml-auto w-4 h-4 text-red-300 hover:bg-neutral-500 cursor-pointer duration-75 mr-2"
onClick={() => modalService.confirmation("Delete this history record ?", () => deleteRecord(record.id))}
/>
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,80 @@
import {useCallback, useEffect, useState} from "react"
import {useParams} from "react-router-dom"
import {useApi} from "../api"
import Button from "../components/Button"
import Card from "../components/Card"
import ProjectHeader from "../components/ProjectHeader"
import {ApiKey, ApiKeyData} from "../core/ApiKey"
import {Project} from "../core/Project"
import {useModal} from "../services/ModalService"
import {ApiKeyForm} from "./projectPage/forms/apiKeyForm"
export function ProjectApiKeysPage() {
const api = useApi()
const modalService = useModal()
const {projectId} = useParams();
const [project, setProject] = useState<Project | null>(null);
const [keys, setKeys] = useState<ApiKey[]>([])
const fetchData = useCallback(async () => {
const project = await api.fetchProject(projectId as string);
setProject(project);
const keys = await api.fetchProjectApiKeys(project.id);
setKeys(keys)
}, [projectId, api])
useEffect(() => {fetchData()}, [fetchData])
if (!project) {
return <div>Loading project...</div>
}
const generateApiKey = async (data: Omit<ApiKeyData, "id">) => {
await api.generateApiKey(project.id, data);
fetchData();
}
const deleteApiKey = async (id: string) => {
await api.deleteApiKey(project.id, id);
fetchData();
}
return (
<div className="f-c gap-4 overflow-scroll">
<ProjectHeader
project={project}
middle={<h3 className="text-xl">Api keys</h3>}
right={<Button
className="ml-auto"
text="Generate API key"
onClick={() => {
modalService.addModal((close) =>
<ApiKeyForm close={close} onCreate={generateApiKey}/>)
}
}
/>}
/>
{
keys.map(key =>
<Card
key={key.id}
header={
<div className="f-r justify-between">
{key.name}
<Button
text="Delete"
color="danger"
onClick={() => {
modalService.confirmation("Delete key ?", () => deleteApiKey(key.id))
}}
/>
</div>}
>
{key.key}
</Card>
)
}
</div>
)
}

View file

@ -0,0 +1,86 @@
import {useCallback, useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import {useApi} from "../api";
import Button from "../components/Button";
import {LogCard} from "../components/LogCard";
import ProjectHeader from "../components/ProjectHeader";
import {Log} from "../core/Log";
import {Project} from "../core/Project";
import { Tag, TagData } from "../core/Tag";
import {useModal} from "../services/ModalService";
import {TagsSettings} from "./projectPage/TagsSettings";
export default function ProjectLogsPage() {
const api = useApi();
const modalService = useModal();
const {projectId} = useParams();
const [project, setProject] = useState<Project | null>(null);
const [logs, setLogs] = useState<Log[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const fetchProject = useCallback(async () => {
const project = await api.fetchProject(projectId as string);
setProject(project);
const logs = await api.fetchProjectLogs(projectId as string);
setLogs(logs);
const tags = await api.fetchProjectTags(projectId as string);
setTags(tags);
}, [projectId, api])
const onLogDeleted = () => {
fetchProject();
}
useEffect(() => {fetchProject()}, [fetchProject])
if (!project) {
return <div>Loading project...</div>
}
const updateTags = async (newTags: TagData[]) => {
const tagsToCreate = newTags.filter(newTag => !newTag.id)
const tagsToDelete = tags.filter(tag => !newTags.find(newTag => newTag.id === tag.id))
const tagsToUpdate = newTags.filter(newTag => {
const tag = tags.find(tag => tag.id === newTag.id)
if (!tag)
return false
return tag.name !== newTag.name || tag.color !== newTag.color
})
await Promise.all(tagsToUpdate.map(async tag => {
if (tag.id)
return api.updateTag(project.id, tag.id, tag)
}))
await Promise.all(tagsToCreate.map(async tag => {
return api.createTag(project.id, tag)
}))
await Promise.all(tagsToDelete.map(async tag => {
if (tag.id)
return api.deleteTag(project.id, tag.id)
}))
await fetchProject()
}
return (
<div className="flex-1 f-c gap-4">
<ProjectHeader
project={project}
middle={<h3 className="text-xl">Logs</h3>}
right={
<Button
className="ml-auto"
text="Manage tags"
onClick={() => modalService.addModal(
(close) => <TagsSettings tags={tags} onSave={(data) => {
updateTags(data);close()
}}/>
)}
/>
}
/>
<div className={`flex flex-col rounded-md gap-2 overflow-scroll`}>
{logs.map(log => <LogCard key={log.id} project={project} log={log} onDelete={onLogDeleted}/>)}
</div>
</div>
)
}

View file

@ -0,0 +1,153 @@
import {ClipboardCopyIcon, CogIcon, DocumentSearchIcon, PlusIcon, TrashIcon} from "@heroicons/react/outline";
import {useCallback, useEffect, useState} from "react";
import {Link, useParams} from "react-router-dom";
import {useApi} from "../api";
import Button from "../components/Button";
import ProjectHeader from "../components/ProjectHeader";
import {Metric, MetricDataOnUpdate} from "../core/Metric";
import {Project} from "../core/Project";
import {MetricForm} from "../forms/createMetricForm";
import {useModal} from "../services/ModalService";
import { copyToClipboard } from "../utils/copyToClipboard";
import {formatTimestamp} from "../utils/formatTimestamp";
import {AddSerieValueForm} from "./projectPage/forms/AddSeriesValueForm";
export type MetricCardProps = {
metric: Metric
onDelete: () => void
onAddDataPoint: () => void
}
export function MetricCard({metric, onDelete, onAddDataPoint}: MetricCardProps) {
const modalService = useModal();
const api = useApi()
const addDataPoint = async (value: number) => {
await api.addValueToSerie(metric.projectId, metric.id, value)
onAddDataPoint()
}
const updateMetric = async (data: MetricDataOnUpdate) => {
await api.updateMetric(metric.projectId, metric.id, data)
onDelete() // I'm lazy
}
const deleteMetric = async () => {
await api.deleteMetric(metric.projectId, metric.id);
onDelete()
}
const metricForm = (close: () => void) =>
<MetricForm
metric={metric}
close={close}
onCreate={updateMetric}
/>
return (
<div className="card f-r">
<div className="flex-1 f-c">
<div className="f-r">
<div className="flex-1"></div>
<div className="f-r gap-2 items-center">
<p className="flex-1 text-center text-xl font-bold">{metric.name}</p>
<ClipboardCopyIcon
className="w-4 h-4 cursor-pointer"
onClick={() => {
copyToClipboard(metric.id)
modalService.info("Metric id copied to clipboard")
}}
/>
</div>
<div className="flex-1 f-r gap-2 justify-end items-center">
<PlusIcon
className="w-4 h-4 text-green-300 hover:bg-neutral-500 cursor-pointer duration-75"
onClick={() => modalService.addModal((close) =>
<AddSerieValueForm series={metric} close={close} onCreate={addDataPoint} />
)}
/>
<CogIcon
className="w-4 h-4 text-neutral-300 hover:bg-neutral-500 cursor-pointer duration-75"
onClick={() => modalService.addModal(metricForm)}
/>
<Link to={`${metric.id}/history`}>
<DocumentSearchIcon
className="w-4 h-4 text-neutral-300 hover:bg-neutral-500 cursor-pointer duration-75"
/>
</Link>
<TrashIcon
className="w-4 h-4 text-red-300 hover:bg-neutral-500 cursor-pointer duration-75 mr-2"
onClick={() => modalService.confirmation(`Delete the metric "${metric.name}" ?`, deleteMetric)}
/>
</div>
</div>
<div className="f-c text-left p-2">
<span>Last update: {formatTimestamp(metric.history?.at(0)?.date || 0)}</span>
<span>Last value: {metric.history?.at(0)?.value}</span>
</div>
</div>
</div>
)
}
export function ProjectMetricsPage() {
const api = useApi();
const modalService = useModal();
const {projectId} = useParams();
const [project, setProject] = useState<Project | null>(null);
const [metrics, setMetrics] = useState<Metric[]>([]);
const fetchProject = useCallback(async () => {
const project = await api.fetchProject(projectId as string);
setProject(project);
const metrics = await api.fetchProjectMetrics(projectId as string);
setMetrics(metrics);
}, [projectId, api])
useEffect(() => {fetchProject()}, [fetchProject])
const onSomethingChanged = () => {
fetchProject();
}
if (!project) {
return <div>Loading project...</div>
}
const metricForm = (close: () => void) =>
<MetricForm
close={close}
onCreate={async (data) => {
close()
await api.createSeries(project.id, data)
onSomethingChanged()
}}
/>
return (
<div className="flex-1 f-c gap-4 overflow-scroll">
<ProjectHeader
project={project}
middle={<h3 className="text-xl">Metrics</h3>}
right={
<Button
className="ml-auto"
text="Create new metric"
onClick={() => modalService.addModal(metricForm)}
/>
}
/>
<div className={`grid grid-cols-2 rounded-md gap-2 overflow-scroll pr-2`}>
{metrics.map(metric =>
<MetricCard
key={metric.id}
metric={metric}
onDelete={onSomethingChanged}
onAddDataPoint={onSomethingChanged}
/>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,106 @@
import {useEffect, useState} from "react";
import {SubmitHandler, useForm} from "react-hook-form";
import {useNavigate} from "react-router-dom";
import {useApi} from "../api"
import AppName from "../components/AppName";
import Button from "../components/Button";
import LineEdit from "../components/Input";
import {useModal} from "../services/ModalService";
interface SignupForm {
email: string
password: string
passwordConfirmation: string
code: string
}
export default function SignupPage() {
const api = useApi();
const navigate = useNavigate();
const modalService = useModal();
const {register, watch, handleSubmit} = useForm<SignupForm>()
const email = watch("email", "")
const password = watch("password", "")
const passwordConfirmation = watch("passwordConfirmation", "")
const [errorMessage, setErrorMessage]= useState<null | string>(null)
const [isUserRegistrationEnabled, setIsUserRegistrationEnabled] = useState<boolean | null>(null)
const isFormValid = () => {
return email && email.length > 5 && password.length > 0 && passwordConfirmation === password ? true : false
}
const handleCreation: SubmitHandler<SignupForm> = async ({email, password, code}) => {
if (!isFormValid()) {
setErrorMessage("Wrong email format or passwords don't match")
return
}
try {
await api.Signup(email, password, code)
modalService.addModal(
(close) => {
navigate("/")
return (
<div className="flex flex-col gap-4 items-center">
<p>Your account has been created, there is no need to confirm your email at the moment,
you can now log in.</p>
<Button text="Log in" onClick={() => {
close()
}}/>
</div>
)
}
)
} catch (err: any) {
console.log(err)
setErrorMessage(err?.message || "An unknown error occured")
}
}
useEffect(() => {
api.isUserRegistrationEnabled().then(setIsUserRegistrationEnabled)
}, [api, setIsUserRegistrationEnabled])
if (isUserRegistrationEnabled === null) {
return <div>Check if user registration is enabled...</div>
} else if (isUserRegistrationEnabled === false ) {
return <div>User registration is disabled</div>
}
return (
<div className="h-screen flex flex-col justify-center items-center gap-4">
<div className="p-4 flex flex-col justify-center items-center bg-neutral-600 rounded-md border border-neutral-500 shadow-sm">
<AppName/>
<form onSubmit={handleSubmit(handleCreation)} className="flex flex-col gap-4">
<LineEdit
type="email"
label="Email"
register={register("email")}
required
/>
<LineEdit
type="password"
label="Password"
register={register("password")}
minLength={6}
required
/>
<LineEdit
type="password"
label="Confirm password"
register={register("passwordConfirmation")}
minLength={6}
required
/>
{errorMessage && <p className="text-red-200">{errorMessage}</p>}
<Button
className="self-center"
color={isFormValid() ? "primary" : "dark"}
text="Sign up"
/>
</form>
</div>
</div>
)
}

View file

@ -0,0 +1,68 @@
import {useState} from "react";
import {SubmitHandler, useForm} from "react-hook-form";
import {Link, Navigate, useNavigate} from "react-router-dom";
import {useApi} from "../api"
import AppName from "../components/AppName";
import Button from "../components/Button";
import LineEdit from "../components/Input";
interface LoginForm {
email: string
password: string
}
export default function LoginPage() {
const api = useApi();
const navigate = useNavigate()
const {register, setValue, handleSubmit} = useForm<LoginForm>()
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
if (api.IsLogged()) {
return <Navigate to="/projects"/>
}
const onLogin: SubmitHandler<LoginForm> = async ({email, password}) => {
try {
setIsLoggingIn(true);
await api.Login(email, password)
navigate("/projects")
} catch (err) {
console.log(err)
setValue("password", "");
setErrorMessage((err as Error).message);
}
setIsLoggingIn(false);
}
return (
<div className="h-screen flex flex-col justify-center items-center gap-4">
<div className="p-4 flex flex-col justify-center items-center bg-neutral-600 rounded-md border border-neutral-500 shadow-sm">
<AppName/>
<form onSubmit={handleSubmit(onLogin)} className="flex flex-col gap-4 mt-4">
<LineEdit
type="email"
label="Email"
register={register("email")}
required
/>
<LineEdit
type="password"
label="Password"
register={register("password")}
required
/>
<Button
className="self-center"
color="green"
text="Log in"
spinner={isLoggingIn}
/>
{errorMessage && <p className="text-red-200">{errorMessage}</p>}
</form>
</div>
<Link to="/signup"><Button text="Create an account" color="dark"/></Link>
</div>
)
}

View file

@ -0,0 +1,20 @@
import {TinyLogCard} from "../../components/LogCard";
import {Log} from "../../core/Log";
import {Project} from "../../core/Project";
interface LogsTabProps {
className?: string
project: Project
logs: Log[]
}
export default function LogsTab({project, logs, className}: LogsTabProps) {
return (
<div className={`bg-neutral-600 rounded-md ${className}`}>
<div className="f-c gap-2 m-2">
<p className="text-left font-bold mb-4">Logs</p>
{logs.map(log => <TinyLogCard key={log.id} project={project} log={log}/>)}
</div>
</div>
)
}

View file

@ -0,0 +1,58 @@
import {Metric} from "../../core/Metric";
import {View} from "../../core/View";
import {DashboardView} from "./forms/OrganizeDashboardForm";
interface MetricCardProps {
metric: Metric
color: string
order: number
}
function MetricCard({info}: {info: MetricCardProps}) {
return (
<div className="flex-1">
<div className="card p-4 f-c text-center" style={{color: info.color || info.metric.color || "white"}}>
<p className="text-3xl">{info.metric.currentValue || "0"}</p>
<p>{info.metric.name}</p>
</div>
</div>
)
}
interface MetricsPanelProps {
metrics: Metric[]
dashboardConfig: View<DashboardView> | null
}
export function MetricsPanel({metrics, dashboardConfig}: MetricsPanelProps) {
let metricsToShow: MetricCardProps[] = []
if (dashboardConfig) {
const metricsConfig = dashboardConfig.config.metrics
for (let i = 0; i < metricsConfig.length; ++i) {
if (!metricsConfig[i].show) {
continue
}
const matchedMetric = metrics.find(metric => metric.id === metricsConfig[i].metricId)
if (!matchedMetric) {
continue
}
metricsToShow.push({
metric: matchedMetric,
color: metricsConfig[i].color,
order: metricsConfig[i].order
})
}
metricsToShow.sort((a, b) => a.order - b.order)
}
return (
<div className="f-r flex-wrap gap-2 overflow-scroll">
{metricsToShow.map(metric =>
<MetricCard
key={metric.metric.id}
info={metric}
/>
)}
</div>
)
}

View file

@ -0,0 +1,85 @@
import {TrashIcon} from "@heroicons/react/outline"
import {useState} from "react"
import Button from "../../components/Button"
import Card from "../../components/Card"
import LineEdit from "../../components/Input"
import {TagData} from "../../core/Tag"
import {useModal} from "../../services/ModalService"
import {TagForm} from "./forms/TagForm"
function clone<T>(x: T): T {
return JSON.parse(JSON.stringify(x))
}
interface TagsSettingsProps {
tags: TagData[]
onSave: (tags: TagData[]) => void
}
export function TagsSettings({tags: originalTags, onSave}: TagsSettingsProps) {
const modalService = useModal()
const [tags, setTags] = useState(originalTags)
const addTag = (tag: TagData) => {
const newTags = clone(tags)
newTags.push(tag)
setTags(newTags)
}
const deleteTag = (tag: TagData) => {
const newTags = clone(tags).filter(newTags => newTags.id !== tag.id)
setTags(newTags)
}
const saveTags = async () => {
onSave(tags);
}
const header =
<div className="f-r justify-between gap-4">
<h2>Tags</h2>
<Button
text="Create tag"
onClick={() => {modalService.addModal((close) => <TagForm close={close} onCreate={addTag}/>)}}
/>
</div>
const setNameOfTag = (tagIndex: number, name: string) => {
const newTags = clone(tags)
newTags[tagIndex].name = name
setTags(newTags)
}
const setColorOfTag = (tagIndex: number, color: string) => {
const newTags = clone(tags)
newTags[tagIndex].color = color
setTags(newTags)
}
return (
<div className="f-c gap-4 flex-1">
<Card header={header}>
<div className="f-c gap-4">
{tags.map((tag, tagIndex) =>
<div className="f-c gap-1">
<div key={tagIndex} className="f-r items-center gap-2">
<LineEdit value={tag.name} onChange={(value) => {setNameOfTag(tagIndex, value)}}/>
<input type="color" value={tag.color} onChange={(event) => {setColorOfTag(tagIndex, event.target.value)}}/>
<TrashIcon
className="w-4 h-4 text-red-300 cursor-pointer"
// I need setTimeout or the modal will close itself, need to investigate
onClick={() => {setTimeout(() => {deleteTag(tag)}, 0)}}
/>
</div>
<div className="px-1 opacity-25 text-xs">
id: {tag.id || "Id will appear after creation"}
</div>
</div>
)}
<Button text="Save" onClick={saveTags}/>
</div>
</Card>
</div>
)
}

View file

@ -0,0 +1,31 @@
import {SubmitHandler, useForm} from "react-hook-form"
import Button from "../../../components/Button";
import LineEdit from "../../../components/Input";
import {ApiKeyData} from "../../../core/ApiKey";
import {Metric, MetricData} from "../../../core/Metric";
import {TagData} from "../../../core/Tag";
interface AddSerieValueFormProps {
series: Metric
close: any
onCreate: (value: number) => void
}
export function AddSerieValueForm(props: AddSerieValueFormProps) {
const {register, handleSubmit} = useForm<{value: number}>();
const addSeriesValue: SubmitHandler<{value: number}> = async (data) => {
props.onCreate(data.value);
props.close();
}
return (
<form onSubmit={handleSubmit(addSeriesValue)} className="f-c gap-2">
<h2 className="text-2xl mb-4 text-left">
Add value to Series {props.series.name}
</h2>
<LineEdit label="Value" type="number" register={register("value")}/>
<Button type="submit" text="Add"/>
</form>
)
}

View file

@ -0,0 +1,31 @@
import {SubmitHandler, useForm} from "react-hook-form"
import Button from "../../../components/Button";
import LineEdit from "../../../components/Input";
import {ApiKeyData} from "../../../core/ApiKey";
import {Metric, MetricData} from "../../../core/Metric";
import {TagData} from "../../../core/Tag";
interface CreateSeriesFormProps {
close: any
onCreate: (name: string, type: string) => void
}
export function CreateSeriesForm(props: CreateSeriesFormProps) {
const {register, handleSubmit} = useForm<{name: string, type: string}>();
const CreateSeries: SubmitHandler<{name: string, type: string}> = async (data) => {
props.onCreate(data.name, data.type);
props.close();
}
return (
<form onSubmit={handleSubmit(CreateSeries)} className="f-c gap-2">
<h2 className="text-2xl mb-4 text-left">
Create Series
</h2>
<LineEdit label="Name" register={register("name")}/>
<LineEdit label="Type" register={register("type")}/>
<Button type="submit" text="Add"/>
</form>
)
}

View file

@ -0,0 +1,299 @@
import {PlusIcon} from "@heroicons/react/outline";
import {useEffect, useState} from "react";
import {SubmitHandler, useForm} from "react-hook-form"
import Button from "../../../components/Button";
import LineEdit from "../../../components/Input";
import {ApiKeyData} from "../../../core/ApiKey";
import {Metric, MetricData} from "../../../core/Metric";
import {TagData} from "../../../core/Tag";
import {View} from "../../../core/View";
import {useModal} from "../../../services/ModalService";
function clone<T>(x: T): T {
return JSON.parse(JSON.stringify(x))
}
interface OrganizeDashboardFormProps {
dashboard: View<DashboardView> | null
metrics: Metric[]
close: any
onSave: (newDashboardConfig: DashboardView) => void
}
type DashboardViewCharts = {
name: string
order: number
metrics: {
metricId: string
color: string
type: string
alwaysUseLastValue: boolean
}[]
}[]
export type DashboardView = {
metrics: {
metricId: string,
name: string
order: number,
show: boolean
color: string
}[]
charts: DashboardViewCharts
}
const optionsCss = "f-r items-center gap-2 bg-neutral-500 rounded-md"
export function OrganizeDashboardForm(props: OrganizeDashboardFormProps) {
const [showGraphs, setShowGraphs] = useState<boolean>(true)
const [metricsOrder, setMetricsOrder] = useState<DashboardView>()
useEffect(() => {
const defaultDashboard: DashboardView = {
metrics: props.metrics.map((metric, index) => ({
metricId: metric.id,
name: metric.name,
order: index,
show: false,
color: "#dddddd"
})),
charts: []
}
let x: DashboardView = defaultDashboard;
if (props.dashboard) {
x = clone(props.dashboard.config)
props.metrics.forEach((metric, index)=> {
if (!x.metrics.find(m => m.metricId === metric.id)) {
x.metrics.push({
metricId: metric.id,
name: metric.name,
order: index,
show: false,
color: "#dddddd"
})
}
})
x.metrics = x.metrics.filter(metric => props.metrics.find(m => m.id === metric.metricId))
}
x.metrics.sort((a, b) => a.order - b.order)
setMetricsOrder(x)
}, [])
if (!metricsOrder) {
return <div>Loading...</div>
}
return (
<div className="f-c gap-2">
<div className="f-r justify-around border-b border-neutral-400 pb-2">
<Button
text="Top row"
color={!showGraphs ? "dark" : "brighter-dark"}
onClick={() => {
setShowGraphs(false)
}}
/>
<Button
text="Graphs"
color={showGraphs ? "dark" : "brighter-dark"}
onClick={() => {
setShowGraphs(true)
}}
/>
</div>
{!showGraphs && metricsOrder.metrics.map((metric, index) => (
<div className="f-r items-stretch border border-neutral-500 p-2 rounded-md gap-2">
<div className="mr-auto f-r items-center">{metric.name}</div>
<div className={`${optionsCss} p-1`}>
Order
<LineEdit
inputClassName="p-0 px-1"
type="number"
className="w-16"
value={metric.order}
onChange={(valueString) => {
const value = parseInt(valueString)
const newMetricsOrder = clone(metricsOrder)
newMetricsOrder.metrics[index].order = value
setMetricsOrder(newMetricsOrder)
}}
/>
</div>
<div className={`${optionsCss} pl-1`}>
Color
<input
type="color"
value={metric.color}
onChange={(valueString) => {
const checked = valueString.target.value
const newMetricsOrder = clone(metricsOrder)
newMetricsOrder.metrics[index].color = checked
setMetricsOrder(newMetricsOrder)
}}
/>
</div>
<div className={`${optionsCss} px-1`}>
<input type="checkbox" checked={metric.show}
onChange={(valueString) => {
const checked = valueString.target.checked
const newMetricsOrder = clone(metricsOrder)
newMetricsOrder.metrics[index].show = checked
setMetricsOrder(newMetricsOrder)
}}
/>
</div>
</div>
))}
{showGraphs &&
<OrganizeCharts
charts={metricsOrder.charts || []}
metrics={props.metrics}
close={props.close}
onChange={(newCharts) => {
const newMetricsOrder = clone(metricsOrder)
newMetricsOrder.charts = newCharts
setMetricsOrder(newMetricsOrder)
}}
/>
}
<Button text="Save" className="ml-auto" onClick={() => {
props.onSave(metricsOrder)
props.close();
}}/>
</div>
)
}
interface OrganizeDashboardChartsProps {
charts: DashboardViewCharts
metrics: Metric[]
close: any
onChange: (charts: DashboardViewCharts) => void
}
function OrganizeCharts({metrics, charts, onChange, close}: OrganizeDashboardChartsProps) {
const addChart = () => {
const newCharts = clone(charts)
newCharts.push({
name: "New chart",
order: 1,
metrics: []
})
onChange(newCharts)
}
const deleteChart = (chartIndex: number) => {
const newCharts = clone(charts)
newCharts.splice(chartIndex, 1)
onChange(newCharts)
}
const setChart = (chartIndex: number, name: string, order: number) => {
const newCharts = clone(charts)
newCharts[chartIndex].name = name
newCharts[chartIndex].order = order
onChange(newCharts)
}
const addMetricToChart = (chartIndex: number) => {
const metric: Metric = metrics[0]
const newCharts = clone(charts)
newCharts[chartIndex].metrics.push({
metricId: metric.id,
color: "red",
type: "line",
alwaysUseLastValue: true
})
onChange(newCharts)
}
const setMetricOnChart = (chartIndex: number, metricIndex: number, newMetric: {metricId: string, type: string, color: string, alwaysUseLastValue: boolean}) => {
const newCharts = clone(charts)
newCharts[chartIndex].metrics[metricIndex] = newMetric
onChange(newCharts)
}
const deleteMetricOnChart = (chartIndex: number, metricIndex: number) => {
const newCharts = clone(charts)
newCharts[chartIndex].metrics.splice(metricIndex, 1)
onChange(newCharts)
}
return (
<div className="f-c gap-2">
<Button text="Add chart" className="mx-auto cursor-pointer" onClick={addChart}/>
{(charts || []).map((chart, chartIndex) => (
<div key={chartIndex} className="f-c border border-neutral-500 p-2 rounded-md gap-2 mb-2">
<div className="f-r items-center gap-2">
<LineEdit className="w-32" value={chart.name} onChange={(event) => { setChart(chartIndex, event, charts[chartIndex].order) }}/>
<div className={`${optionsCss} p-1`}>
Order
<LineEdit
inputClassName="p-0 px-1"
type="number"
className="w-16"
value={chart.order}
onChange={(event) => {
setChart(chartIndex, charts[chartIndex].name, parseInt(event))
}}
/>
</div>
<Button
text="Add metric"
onClick={() => { addMetricToChart(chartIndex) }}
/>
<Button
text="Delete chart"
color="danger"
onClick={() => { setTimeout(() => deleteChart(chartIndex), 0) }}
/>
</div>
<div className="f-c gap-2">
{chart.metrics.map((selectedMetric, selectedMetricIndex) =>
<div key={selectedMetricIndex} className="f-r items-center gap-2">
<select onChange={(event) => { setMetricOnChart(chartIndex, selectedMetricIndex, {...selectedMetric, metricId: event.target.value}) }} className="bg-neutral-500 p-1 rounded-md">
{metrics.map(metric =>
<option
value={metric.id}
selected={metric.id === selectedMetric.metricId}
>
{metric.name}
</option>)}
</select>
<select onChange={(event) => { setMetricOnChart(chartIndex, selectedMetricIndex, {...selectedMetric, type: event.target.value}) }} className="bg-neutral-500 p-1 rounded-md">
<option
value="line"
selected={selectedMetric.type === "line"}
>
Line
</option>
<option
value="bar"
selected={selectedMetric.type === "bar"}
>
Bar
</option>
</select>
<div className={`${optionsCss} p-1`}>
Always use last value
<input type="checkbox" checked={selectedMetric.alwaysUseLastValue}
onChange={(event) => { setMetricOnChart(chartIndex, selectedMetricIndex, {...selectedMetric, alwaysUseLastValue: event.target.checked}) }}
/>
</div>
<input type="color" value={selectedMetric.color}
onChange={(event) => { setMetricOnChart(chartIndex, selectedMetricIndex, {...selectedMetric, color: event.target.value}) }}
/>
<Button color="danger" text="delete" onClick={() => {
setTimeout(() => deleteMetricOnChart(chartIndex, selectedMetricIndex), 0)
}}/>
</div>
)}
</div>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,32 @@
import {SubmitHandler, useForm} from "react-hook-form"
import Button from "../../../components/Button";
import LineEdit from "../../../components/Input";
import {ApiKeyData} from "../../../core/ApiKey";
import {TagData} from "../../../core/Tag";
interface TagFormProps {
tag?: TagData
close: any
onCreate: any
}
export function TagForm(props: TagFormProps) {
const {register, handleSubmit} = useForm<TagData>({defaultValues: props.tag});
const createTag: SubmitHandler<TagData> = async (data) => {
props.onCreate(data);
props.close();
}
return (
<form onSubmit={handleSubmit(createTag)} className="f-c gap-2">
<h2 className="text-2xl mb-4 text-left">
{!props.tag && "Create new tag"}
{props.tag && "Update tag"}
</h2>
<LineEdit label="Name" register={register("name")}/>
<input type="color" className="w-full" {...register("color")}/>
<Button type="submit" text={props.tag ? "Save" : "Create"}/>
</form>
)
}

View file

@ -0,0 +1,26 @@
import {SubmitHandler, useForm} from "react-hook-form"
import Button from "../../../components/Button";
import LineEdit from "../../../components/Input";
import {ApiKeyData} from "../../../core/ApiKey";
interface ApiKeyFormProps {
close: any
onCreate: any
}
export function ApiKeyForm(props: ApiKeyFormProps) {
const {register, handleSubmit} = useForm<Omit<ApiKeyData, "id">>();
const createApiKey: SubmitHandler<Omit<ApiKeyData, "id">> = async (data) => {
props.onCreate(data);
props.close();
}
return (
<form onSubmit={handleSubmit(createApiKey)} className="f-c gap-2">
<h2 className="text-2xl mb-4 text-left">Generate a new API key</h2>
<LineEdit label="Name" register={register("name")}/>
<Button type="submit" text="Create"/>
</form>
)
}

View file

@ -0,0 +1,125 @@
import {CogIcon} from "@heroicons/react/outline";
import {useCallback, useEffect, useState} from "react";
import { useParams} from "react-router-dom";
import {useApi} from "../../api"
import Card from "../../components/Card";
import Chart from "../../components/Chart";
import ProjectHeader from "../../components/ProjectHeader";
import {Log} from "../../core/Log";
import {Metric} from "../../core/Metric";
import {Project} from "../../core/Project";
import {View} from "../../core/View";
import {useModal} from "../../services/ModalService";
import {DashboardView, OrganizeDashboardForm} from "./forms/OrganizeDashboardForm";
import LogsTab from "./LogsTab";
import {MetricsPanel} from "./MetricsPanel";
export default function ProjectPage() {
const api = useApi();
const modalService = useModal();
const {projectId} = useParams();
const [project, setProject] = useState<Project | null>(null);
const [logs, setLogs] = useState<Log[]>([]);
const [metrics, setMetrics] = useState<Metric[]>([]);
const [dashboardConfig, setDashboardConfig] = useState<View<DashboardView> | null>(null)
const fetchProject = useCallback(async () => {
const project = await api.fetchProject(projectId as string);
setProject(project);
const logs = await api.fetchProjectLogs(projectId as string);
setLogs(logs);
const metrics = await api.fetchProjectMetrics(projectId as string);
setMetrics(metrics);
const dashboardConfig = await api.fetchDashboardView(project.id)
setDashboardConfig(dashboardConfig)
}, [projectId, api])
useEffect(() => {fetchProject()}, [fetchProject])
if (!project) {
return <div>Loading project...</div>
}
const onSaveDashboard = async (dashboard: DashboardView) => {
if (!dashboardConfig) {
await api.createView(project.id, {
provider: "Web",
type: "dashboard",
name: "dashboard",
config: dashboard
})
return fetchProject();
}
await api.updateView(project.id, dashboardConfig, {name: "dashboard", config: dashboard});
fetchProject();
}
return (
<div className="flex-1 f-r h-full overflow-hidden">
<div className="flex-1 f-c gap-2 overflow-hidden">
<ProjectHeader
project={project}
middle={
<div className="f-r justify-center items-center gap-2 px-1">
<h3 className="text-xl">Dashboard</h3>
<CogIcon
className="w-4 h-4 cursor-pointer"
onClick={async () => {modalService.addModal((close) =>
<OrganizeDashboardForm
metrics={metrics}
dashboard={dashboardConfig}
close={close}
onSave={onSaveDashboard}
/>
)}}
/>
</div>
}
/>
<MetricsPanel metrics={metrics} dashboardConfig={dashboardConfig}/>
<div className="flex-1 f-r gap-2 overflow-hidden">
<LogsTab
className="flex-1 overflow-scroll"
project={project}
logs={logs}
/>
<div className="flex-1 f-c gap-2 overflow-scroll">
{(dashboardConfig?.config.charts || []).map(chart => {
const data = chart.metrics.map(line => {
const metric = metrics.find(metricc => metricc.id === line.metricId)
return {
name: metric?.name || "default",
type: line.type,
color: line.color !== '' ? line.color : "red",
alwaysUseLastValue: line.alwaysUseLastValue,
data: (metric?.history || []).map(metricHistory => {
return {value: metricHistory.value, date: metricHistory.date}
})
}
})
return (
<Card className="flex-1 f-c">
<div className="f-r justify-center gap-8">
{chart.metrics.map(metricChart => {
const metric = metrics.find(metric => metric.id === metricChart.metricId)
return <div className="f-r items-center gap-2" style={{color: metricChart.color}}><span className="text-3xl">{metric?.currentValue}</span> {metric?.name}</div>
})}
</div>
<div className="flex-1">
<Chart charts={data}/>
</div>
</Card>
)
})}
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,54 @@
import {useCallback, useEffect, useState} from "react";
import {Link} from "react-router-dom";
import {useApi} from "../api"
import Button from "../components/Button";
import {ProjectSideBar} from "../components/ProjectSideBar";
import {Project} from "../core/Project";
import {CreateProjectForm} from "../forms/createProjectForm";
import {useModal} from "../services/ModalService";
export default function Projects() {
const api = useApi();
const modalService = useModal();
const [projects, setProjects] = useState<Project[]>([]);
const fetchProjects = useCallback(async () => {
const projects = await api.fetchProjects();
setProjects(projects);
}, [api])
const createProject = useCallback(async (data: any) => {
await api.createProject(data);
fetchProjects();
}, [api, fetchProjects])
const showCreateProjectForm = useCallback(async () => {
modalService.addModal((close) => <CreateProjectForm close={close} onCreate={createProject}/>)
}, [createProject, modalService])
useEffect(() => {fetchProjects()}, [fetchProjects])
return (
<div className="h-screen flex-1 f-r">
<ProjectSideBar/>
<div className="flex-1 f-c gap-4 px-4">
<div className="f-r justify-between border-b border-b-neutral-500 py-4">
<h1 className="text-4xl">Projects</h1>
<Button text="Create project" onClick={showCreateProjectForm}/>
</div>
<div className="f-r flex-wrap gap-4">
{projects.map(project => (
<Link
key={project.id}
to={`/projects/${project.id}`}
className="flex-1 f-c gap-4 bg-neutral-600 p-4 text-left rounded-md cursor-pointer hover:bg-neutral-500 duration-150">
{project.name}
</Link>
))}
</div>
</div>
</div>
)
}

1
web-app/src/react-app-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View file

@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View file

@ -0,0 +1,82 @@
import {createContext, useCallback, useContext, useState} from "react";
import Button from "../components/Button";
import Modal from "../components/Modal";
type cb = (close: any) => JSX.Element
export interface ModalContextProps {
modals: JSX.Element[]
addModal: (cb: cb) => void
info: (description: string) => void
error: (description: string) => void
confirmation: (description: string, onConfirmation: () => void) => void
closeLast: () => void
}
export const ModalContext = createContext<ModalContextProps>({} as ModalContextProps)
const value: ModalContextProps = {} as ModalContextProps // See comments below
export function ModalServiceProvider({children}: any) {
const [modals, setModals] = useState<JSX.Element[]>([])
const closeCurrentModal = () => {
modals.splice(-1, 1)
setModals([...modals])
}
const addModal = (cb: cb) => {
const content = cb(() => {value.closeLast()});
setModals([...modals, content])
}
const error = (description: string) => {
addModal((close) => <p>{description}</p>)
}
const confirmation = (description: string, onConfirmation: () => void) => {
addModal((close) =>
<div className="f-c gap-4">
<p className="text-left">{description}</p>
<div className="f-r gap-2">
<Button text="Delete" color="danger" className="ml-auto" onClick={() => {
onConfirmation()
close()
}}/>
<Button text="Cancel" onClick={close}/>
</div>
</div>
)}
// Because this function is designed to be used with nested modals, we must ensure that the "root" object is the
// same and wont change so the updated functions (addModal) is called
value.modals = modals;
value.addModal = addModal;
value.info = error;
value.error = error;
value.confirmation = confirmation;
value.closeLast = closeCurrentModal
return (
<ModalContext.Provider value={value}>
{children}
{modals.map((modal, index) =>
<Modal
key={index}
customId={`modal-${index}`}
show={modals.length > 0}
onClose={index === modals.length - 1 ? () => {closeCurrentModal()} : () => {}}
ignoreInputs={index != modals.length - 1}
>
{modal}
</Modal>
)}
</ModalContext.Provider>
)
}
export function useModal() {
return useContext(ModalContext);
}

View file

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View file

@ -0,0 +1,3 @@
export function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
}

View file

@ -0,0 +1,5 @@
import moment from "moment";
export function formatTimestamp(timestamp: number): string {
return moment(timestamp).format("YYYY-MM-DD hh:mm a")
}