This commit is contained in:
Vyn 2025-06-11 09:50:52 +02:00
commit 61cbd57af1
168 changed files with 31208 additions and 0 deletions

2
web-app/.env.development Normal file
View file

@ -0,0 +1,2 @@
REACT_APP_API_URL=http://localhost:8080
REACT_APP_DOCS_URL=http://localhost:8000

4
web-app/.env.production Normal file
View file

@ -0,0 +1,4 @@
REACT_APP_API_URL=http://localhost:8080
REACT_APP_DOCS_URL=https://docs.awary.app
DOCKER_SERVER_PORT=3000

17587
web-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

56
web-app/package.json Normal file
View file

@ -0,0 +1,56 @@
{
"name": "front",
"version": "0.1.0",
"private": true,
"dependencies": {
"@heroicons/react": "^1.0.6",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.11.45",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/recharts": "^1.8.24",
"moment": "^2.29.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.33.1",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"recharts": "^2.3.2",
"typescript": "^4.7.4",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"docker:serve": "npx env-cmd -f .env.production sh docker-serve.sh"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"autoprefixer": "^10.4.7",
"postcss": "^8.4.14",
"serve": "^14.0.1",
"tailwindcss": "^3.1.6"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

43
web-app/public/index.html Normal file
View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/awary_logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Simple, open-source, events logging via HTTP"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/awary_logo.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<!--<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />-->
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Awary</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

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")
}

View file

@ -0,0 +1,17 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
colors: {
app: {
primary: "#36CCCC"
},
primary: "#36AAAA",
"primary-hover": "#368888",
"primary-active": "#367777",
}
}
},
plugins: [],
}

26
web-app/tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}