init git
commit
acb3b0b80f
@ -0,0 +1,3 @@
|
||||
VITE_ASANA_API_URI=
|
||||
VITE_ASANA_PROJECT_ID=
|
||||
VITE_ACCESS_TOKEN=
|
@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
@ -0,0 +1,34 @@
|
||||
# One.com Asana Incidents Viz
|
||||
|
||||
This is a single page application written using React and Tailwind css, and uses
|
||||
(Vite JS)[https://vitejs.dev] as the bundler/dev server.
|
||||
|
||||
This project heavily relies on the Asana v1.0 API and thus any changes to the API
|
||||
will result in a non-functioning viz tool.
|
||||
|
||||
## Development setup
|
||||
|
||||
1. Clone this repo
|
||||
2. Install node dependencies: `npm install`
|
||||
3. Copy the `.env.example` file as `.env.local` and fill in the required values.
|
||||
4. Run the local dev server: `npm run dev`
|
||||
|
||||
```
|
||||
Note: Asana API URI can be set as https://app.asana.com/api/1.0
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
1. Clone this repo
|
||||
2. Install node dependencies: `npm install`
|
||||
3. Either follow step 3 from the development setup OR provide the same variables to the build context
|
||||
4. Run the build command: `npm run build`
|
||||
5. Deploy the `dist` directory to any web server of your choice
|
||||
|
||||
```
|
||||
Note: Make sure you don't copy the node_modules folder if you are making a zip.
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under MIT.
|
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Incidents and Maintenance Viz | one.com</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "one-com-viz",
|
||||
"version": "1.0.0",
|
||||
"author": "Ayush Mukherjee <me@ayushm.com>",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.21.1",
|
||||
"chart.js": "^3.2.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^17.0.0",
|
||||
"react-chartjs-2": "^3.0.3",
|
||||
"react-dom": "^17.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.3.2",
|
||||
"@vitejs/plugin-react-refresh": "^1.3.1",
|
||||
"autoprefixer": "^10.2.5",
|
||||
"postcss": "^8.2.15",
|
||||
"tailwindcss": "^2.1.4",
|
||||
"vite": "^2.3.3"
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import _ from 'lodash'
|
||||
import { getFields, getTasks } from './services/AsanaService'
|
||||
import IncidentsChart from './components/IncidentsChart'
|
||||
import ProductsChart from './components/ProductsChart'
|
||||
import ServicesChart from './components/ServicesChart'
|
||||
import ServiceInfo from './components/ServiceInfo'
|
||||
|
||||
|
||||
const App = () => {
|
||||
const [fields, setFields] = useState([])
|
||||
const [tasks, setTasks] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [statusMsg, setStatusMsg] = useState('Loading...')
|
||||
|
||||
useEffect(async () => {
|
||||
// get information from api
|
||||
setStatusMsg('Getting fields information...')
|
||||
await getFields(setFields)
|
||||
|
||||
setStatusMsg('Fetching records...')
|
||||
await getTasks(setTasks)
|
||||
|
||||
// set loading to false
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
return <div className="container mx-auto pt-4 px-4 min-h-screen overflow-x-hidden">
|
||||
{
|
||||
loading === true ? statusMsg : <>
|
||||
<div className="flex flex-col">
|
||||
<div className="my-2">
|
||||
<h1 className="font-bold text-2xl text-center">Incidents Graph</h1>
|
||||
</div>
|
||||
<div>
|
||||
<IncidentsChart atasks={tasks} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col">
|
||||
<div className="my-2">
|
||||
<h1 className="font-bold text-2xl text-center">Impacted Products Graph</h1>
|
||||
</div>
|
||||
<div>
|
||||
<ProductsChart fields={fields} tasks={tasks} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col">
|
||||
<div className="my-2">
|
||||
<h1 className="font-bold text-2xl text-center">Service Impact Graphs</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap md:flex-nowrap">
|
||||
<ServicesChart fields={fields[0]} idx={0} tasks={tasks} />
|
||||
<ServicesChart fields={fields[1]} idx={1} tasks={tasks} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col my-4">
|
||||
<div className="mb-2">
|
||||
<p className="text-xl">Service Impact Information</p>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<ServiceInfo fields={fields} tasks={tasks} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default App
|
@ -0,0 +1,58 @@
|
||||
import React, { memo, useEffect } from 'react'
|
||||
import array from 'lodash/array'
|
||||
import { Line } from 'react-chartjs-2'
|
||||
import { getRandomColor } from '../utils'
|
||||
|
||||
const IncidentsChart = ({ atasks }) => {
|
||||
|
||||
let tasks = [...atasks]
|
||||
array.reverse(tasks)
|
||||
const monthNames = ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
];
|
||||
const dates = []
|
||||
const counts = []
|
||||
const bgColors = []
|
||||
|
||||
useEffect(() => {
|
||||
tasks.forEach((t) => {
|
||||
const d = new Date(t.created_at)
|
||||
const dstr = monthNames[d.getMonth()] + ' ' + d.getFullYear()
|
||||
if (dates.indexOf(dstr) === -1) {
|
||||
dates.push(dstr)
|
||||
counts.push(1)
|
||||
bgColors.push(getRandomColor())
|
||||
} else {
|
||||
counts[dates.indexOf(dstr)] += 1
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return <div className="my-2 h-64 w-full">
|
||||
<Line
|
||||
data={{
|
||||
labels: dates,
|
||||
datasets: [{
|
||||
data: counts,
|
||||
borderColor: 'rgba(29, 78, 216, 1)'
|
||||
}],
|
||||
}}
|
||||
options={{
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
yAxis: {
|
||||
min: 0
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
export default memo(IncidentsChart)
|
@ -0,0 +1,46 @@
|
||||
import React, { memo } from 'react'
|
||||
import { Pie } from 'react-chartjs-2'
|
||||
import { getRandomColor } from '../utils'
|
||||
|
||||
const ProductsChart = ({ fields, tasks }) => {
|
||||
|
||||
const products = fields[2].options
|
||||
const counts = []
|
||||
const bgColors = []
|
||||
|
||||
products.forEach(() => {
|
||||
counts.push(0)
|
||||
bgColors.push(getRandomColor())
|
||||
})
|
||||
|
||||
tasks.forEach((t) => {
|
||||
if (t.custom_fields[2].enum_value) {
|
||||
counts[products.indexOf(t.custom_fields[2].enum_value.name)] += 1
|
||||
} else if (t.custom_fields[3].enum_value) {
|
||||
counts[products.indexOf(t.custom_fields[3].enum_value.name)] += 1
|
||||
}
|
||||
})
|
||||
|
||||
return <div className="my-2 h-64 w-full">
|
||||
<Pie
|
||||
data={{
|
||||
labels: products,
|
||||
datasets: [{
|
||||
data: counts,
|
||||
backgroundColor: bgColors,
|
||||
}],
|
||||
}}
|
||||
options={{
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
export default memo(ProductsChart)
|
@ -0,0 +1,79 @@
|
||||
import React, { memo, useState, useEffect } from 'react'
|
||||
import array from 'lodash/array'
|
||||
import ServiceProductChart from './ServiceProductChart'
|
||||
|
||||
const ServiceInfo = ({ fields, tasks }) => {
|
||||
const [services, setServices] = useState([])
|
||||
|
||||
const [selection, setSelection] = useState(null)
|
||||
|
||||
const [fTasks, setTasks] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
setServices(array.union(fields[0].options, fields[1].options))
|
||||
}, [])
|
||||
|
||||
const filterTasksByService = (service) => {
|
||||
setTasks(tasks.filter((t) =>
|
||||
t.custom_fields[0].enum_value !== null && t.custom_fields[0].enum_value.name === service
|
||||
|| t.custom_fields[1].enum_value !== null && t.custom_fields[1].enum_value.name === service
|
||||
))
|
||||
}
|
||||
|
||||
const setSelect = (e) => {
|
||||
setSelection(e.currentTarget.value)
|
||||
filterTasksByService(e.currentTarget.value)
|
||||
}
|
||||
|
||||
// Asssuming Asana structure doesn't change
|
||||
const getProductsAndDetails = (t) => {
|
||||
const prod1 = t.custom_fields[2].enum_value?.name
|
||||
const prod2 = t.custom_fields[3].enum_value?.name
|
||||
|
||||
if (prod1 && prod2) {
|
||||
return prod1 + ', ' + prod2
|
||||
} else if (prod1) {
|
||||
return prod1
|
||||
} else if (prod2) {
|
||||
return prod2
|
||||
}
|
||||
|
||||
return 'None'
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className="flex flex-col w-96">
|
||||
<label htmlFor="serviceSelect">Select Service:</label>
|
||||
<select onChange={setSelect} defaultValue="0">
|
||||
<option value="0" disabled>Select an option...</option>
|
||||
{ services.map((s, idx) => (
|
||||
<option key={idx} value={s}> {s} </option>
|
||||
)) }
|
||||
</select>
|
||||
</div>
|
||||
{ selection !== null ? <>
|
||||
<div className="mt-2 px-2 py-4 flex flex-col md:flex-row justify-center">
|
||||
{ fTasks.length !== 0 ?
|
||||
<ol className="list-decimal list-inside">
|
||||
{ fTasks.map((t, idx) => (
|
||||
<li className="px-2 my-4" key={idx}>
|
||||
{t.name}
|
||||
<ul className="list-disc list-inside px-2 py-2">
|
||||
<li><strong className="font-bold">Reason: </strong> {t.custom_fields[4].text_value || 'None'} </li>
|
||||
<li><strong className="font-bold">Impacted Products: </strong> {
|
||||
getProductsAndDetails(t)
|
||||
} </li>
|
||||
</ul>
|
||||
</li>
|
||||
)) }
|
||||
</ol>
|
||||
: 'None' }
|
||||
<div className="w-full h-64 md:w-1/2">
|
||||
<ServiceProductChart atasks={fTasks} />
|
||||
</div>
|
||||
</div>
|
||||
</> : '' }
|
||||
</>
|
||||
}
|
||||
|
||||
export default memo(ServiceInfo)
|
@ -0,0 +1,69 @@
|
||||
import React, { memo } from 'react'
|
||||
import array from 'lodash/array'
|
||||
import { Line } from 'react-chartjs-2'
|
||||
import { getRandomColor } from '../utils'
|
||||
|
||||
const ServiceProductChart = ({atasks}) => {
|
||||
|
||||
let tasks = [...atasks]
|
||||
const monthNames = ["January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
];
|
||||
const labels = []
|
||||
const prods = []
|
||||
const data = []
|
||||
const datasets = []
|
||||
|
||||
array.reverse(tasks)
|
||||
|
||||
tasks.forEach((t) => {
|
||||
const d = new Date(t.created_at)
|
||||
const dstr = d.getDate() + ' ' + monthNames[d.getMonth()] + ' ' + d.getFullYear()
|
||||
labels.push(dstr)
|
||||
if (t.custom_fields[2].display_value !== null && prods.indexOf(t.custom_fields[2].display_value) === -1) {
|
||||
prods.push(t.custom_fields[2].display_value)
|
||||
} else if (t.custom_fields[3].display_value !== null && prods.indexOf(t.custom_fields[3].display_value) === -1) {
|
||||
prods.push(t.custom_fields[3].display_value)
|
||||
}
|
||||
})
|
||||
|
||||
prods.forEach(() => data.push([]))
|
||||
|
||||
if (prods.length > 0) {
|
||||
tasks.forEach((t) => {
|
||||
prods.forEach((p, idx) => {
|
||||
if (t.custom_fields[2].display_value === p || t.custom_fields[3].display_value === p) {
|
||||
data[idx].push(1)
|
||||
} else {
|
||||
data[idx].push(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
prods.forEach((p, idx) => {
|
||||
datasets.push({
|
||||
label: p,
|
||||
data: data[idx],
|
||||
borderColor: getRandomColor(),
|
||||
})
|
||||
})
|
||||
|
||||
return <Line
|
||||
data={{
|
||||
labels,
|
||||
datasets,
|
||||
}}
|
||||
options={{
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
yAxis: {
|
||||
min: 0
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
}
|
||||
|
||||
export default ServiceProductChart
|
@ -0,0 +1,50 @@
|
||||
import React, { memo } from 'react'
|
||||
import { Bar } from 'react-chartjs-2'
|
||||
import { getRandomColor } from '../utils'
|
||||
|
||||
const Chart = ({ fields, idx, tasks }) => {
|
||||
|
||||
const labels = fields.options
|
||||
const data = []
|
||||
const bgColors = []
|
||||
|
||||
labels.forEach((l) => {
|
||||
let count = 0
|
||||
tasks.forEach((t) => {
|
||||
if (t.custom_fields[idx].enum_value != null && t.custom_fields[idx].enum_value.name === l) {
|
||||
count += 1
|
||||
}
|
||||
})
|
||||
data.push(count)
|
||||
bgColors.push(getRandomColor())
|
||||
})
|
||||
|
||||
return <div className="my-2 h-96 w-full md:w-1/2">
|
||||
<Bar
|
||||
data={{
|
||||
labels,
|
||||
datasets: [{
|
||||
data,
|
||||
backgroundColor: bgColors,
|
||||
}],
|
||||
}}
|
||||
options={{
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: fields.name,
|
||||
font: {
|
||||
size: '20'
|
||||
},
|
||||
},
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default memo(Chart)
|
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
)
|
@ -0,0 +1,43 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const getFields = async (setFields) => {
|
||||
const fArr = [];
|
||||
const res = await axios.get(`${import.meta.env.VITE_ASANA_API_URI}/projects/${import.meta.env.VITE_ASANA_PROJECT_ID}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${import.meta.env.VITE_ACCESS_TOKEN}`
|
||||
}
|
||||
});
|
||||
res.data.data.custom_field_settings.forEach((a) => {
|
||||
const f = {
|
||||
id: a.gid,
|
||||
name: a.custom_field.name,
|
||||
type: a.custom_field.type,
|
||||
};
|
||||
const opts = [];
|
||||
if (a.custom_field.type == 'enum') {
|
||||
a.custom_field.enum_options.forEach((o) => {
|
||||
opts.push(o.name);
|
||||
});
|
||||
f.options = opts;
|
||||
}
|
||||
fArr.push(f);
|
||||
});
|
||||
setFields(fArr);
|
||||
}
|
||||
|
||||
export const getTasks = async (setTasks) => {
|
||||
const res2 = await axios.get(`${import.meta.env.VITE_ASANA_API_URI}/tasks`, {
|
||||
params: {
|
||||
section: '1199915951089746',
|
||||
opt_fields: 'name,created_at,custom_fields',
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${import.meta.env.VITE_ACCESS_TOKEN}`
|
||||
}
|
||||
});
|
||||
const data = res2.data.data;
|
||||
data.sort((a, b) => {
|
||||
return new Date(b.created_at) - new Date(a.created_at);
|
||||
});
|
||||
setTasks(data);
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
export const getRandomColor = () => {
|
||||
let letters = '0123456789ABCDEF'.split('');
|
||||
let color = '#';
|
||||
for (let i = 0; i < 6; i++ ) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
purge: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
mode: 'jit',
|
||||
darkMode: false,
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import reactRefresh from '@vitejs/plugin-react-refresh'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [reactRefresh()]
|
||||
})
|
Loading…
Reference in New Issue