├── .env.example ├── public ├── favicon.ico ├── manifest.json └── index.html ├── netlify.toml ├── src ├── setupProxy.js ├── index.js ├── App.css ├── index.css ├── lambda │ └── graphql.js └── App.js ├── README.md ├── .gitignore ├── LICENSE └── package.json /.env.example: -------------------------------------------------------------------------------- 1 | ALGOLIA_APP_ID= 2 | ALGOLIA_API_KEY= 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yarnpkg/search-indexer-dashboard/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "yarn build" 3 | functions = "lambda" 4 | publish = "build" 5 | 6 | [[redirects]] 7 | from = "https://yarn-search-status.netlify.com/*" 8 | to = "https://search-status.yarnpkg.com/:splat" 9 | status = 301 10 | force = true 11 | -------------------------------------------------------------------------------- /src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const proxy = require('http-proxy-middleware'); 2 | 3 | module.exports = function(app) { 4 | app.use(proxy('/.netlify/functions/', { 5 | target: 'http://localhost:9000/', 6 | "pathRewrite": { 7 | "^/\\.netlify/functions": "" 8 | } 9 | })); 10 | }; 11 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Yarn Status", 3 | "name": "Yarn Search Status", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Yarn search indexer status page 2 | 3 | https://yarn-search-status.netlify.com 4 | 5 | This is a small dashboard (and graphql endpoint) for seeing the status of the [npm -> Algolia indexer](https://github.com/algolia/npm-search). 6 | 7 | ## Setting this up yourself 8 | 9 | - If you want this to be deployed: use Netlify 10 | - set the env variables in a file `.env` copied from `.env.example` 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /lambda 12 | 13 | /.netlify 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { GraphQLClient, ClientContext } from 'graphql-hooks'; 4 | 5 | import './index.css'; 6 | import App from './App'; 7 | 8 | const client = new GraphQLClient({ 9 | url: '/.netlify/functions/graphql', 10 | }); 11 | 12 | ReactDOM.render( 13 | 14 | 15 | , 16 | document.getElementById('root') 17 | ); 18 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .table { 2 | display: grid; 3 | grid-template-columns: 4fr 4fr 4fr; 4 | grid-row-gap: 3em; 5 | } 6 | 7 | .table-item { 8 | --col-span: 'not overriden'; 9 | text-align: center; 10 | display: grid; 11 | grid-template-columns: 1fr; 12 | grid-column: 1 / span var(--col-span); 13 | } 14 | 15 | .mega { 16 | font-size: 3em; 17 | font-weight: 700; 18 | } 19 | 20 | .massive { 21 | font-size: 5em; 22 | font-weight: 700; 23 | } 24 | 25 | .medium { 26 | font-size: 1.5em; 27 | font-weight: 500; 28 | } 29 | 30 | .title { 31 | text-align: center; 32 | font-size: 1.5em; 33 | margin-bottom: 2em; 34 | } 35 | 36 | .raw-data { 37 | position: absolute; 38 | bottom: -2em; 39 | background: white; 40 | } 41 | 42 | .header { 43 | font-size: 2em; 44 | display: flex; 45 | justify-content: space-between; 46 | } 47 | 48 | .info-icon { 49 | vertical-align: bottom; 50 | } 51 | 52 | .info-icon svg { 53 | vertical-align: bottom; 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yarn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-lambda", 3 | "version": "0.4.0", 4 | "private": true, 5 | "dependencies": { 6 | "algoliasearch": "^3.32.0", 7 | "apollo-server-lambda": "^2.5.0", 8 | "bufferutil": "^4.0.1", 9 | "dotenv": "^6.2.0", 10 | "encoding": "^0.1.12", 11 | "got": "^9.6.0", 12 | "graphql": "^14.1.1", 13 | "graphql-hooks": "^3.2.2", 14 | "ms": "^2.1.1", 15 | "react": "^16.8.3", 16 | "react-dom": "^16.8.3", 17 | "react-scripts": "^2.1.3", 18 | "utf-8-validate": "^5.0.2" 19 | }, 20 | "scripts": { 21 | "start": "run-p start:**", 22 | "start:app": "react-scripts start", 23 | "start:lambda": "netlify-lambda serve src/lambda", 24 | "build": "run-p build:**", 25 | "build:app": "react-scripts build", 26 | "build:lambda": "netlify-lambda build src/lambda", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": "react-app" 32 | }, 33 | "browserslist": [ 34 | ">0.2%", 35 | "not dead", 36 | "not ie <= 11", 37 | "not op_mini all" 38 | ], 39 | "devDependencies": { 40 | "@babel/plugin-transform-object-assign": "^7.0.0", 41 | "babel-loader": "8.0.4", 42 | "http-proxy-middleware": "^0.19.0", 43 | "netlify-lambda": "^1.4.13", 44 | "npm-run-all": "^4.1.5" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --monospace: Menlo, monospace; 3 | --sans-serif: -apple-system, BlinkMacSystemFont, sans-serif; 4 | } 5 | 6 | body { 7 | font-family: var(--sans-serif); 8 | color: rgb(34, 39, 39); 9 | } 10 | 11 | .error { 12 | color: red; 13 | background-color: pink; 14 | border: 0.2em solid currentColor; 15 | padding: 1em; 16 | margin: 1em; 17 | font-family: var(--monospace); 18 | } 19 | 20 | code, 21 | pre { 22 | font-family: var(--monospace); 23 | } 24 | 25 | svg { 26 | width: 1em; 27 | height: 1em; 28 | } 29 | 30 | .inaccessible-link { 31 | color: currentColor; 32 | } 33 | 34 | .inaccessible-button { 35 | font-size: 1em; 36 | height: 1em; 37 | background: none; 38 | border: none; 39 | padding: 0; 40 | } 41 | 42 | .inaccessible-button:active { 43 | color: currentColor; 44 | } 45 | 46 | .spin { 47 | animation: spin 1s linear infinite; 48 | } 49 | 50 | @keyframes spin { 51 | 100% { 52 | transform: rotate(-360deg); 53 | } 54 | } 55 | 56 | progress[value] { 57 | appearance: none; 58 | width: 100%; 59 | height: 0.5em; 60 | } 61 | 62 | progress[value]::-webkit-progress-bar { 63 | background: none; 64 | } 65 | 66 | progress[value]::-webkit-progress-inner-element { 67 | border: 1px solid currentColor; 68 | } 69 | 70 | progress[value]::-webkit-progress-value { 71 | background-color: currentColor; 72 | } 73 | 74 | button { 75 | color: currentColor; 76 | } 77 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 16 | 25 | Yarn search status 26 | 27 | 28 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/lambda/graphql.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { ApolloServer, gql } = require('apollo-server-lambda'); 3 | const algoliasearch = require('algoliasearch'); 4 | const got = require('got'); 5 | 6 | const { ALGOLIA_APP_ID, ALGOLIA_API_KEY } = process.env; 7 | 8 | const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_API_KEY); 9 | 10 | // The GraphQL schema 11 | const typeDefs = gql` 12 | # Status of an Algolia index 13 | type IndexStatus { 14 | nbHits: Int 15 | } 16 | 17 | type IndexesStatus { 18 | main: IndexStatus 19 | bootstrap: IndexStatus 20 | } 21 | 22 | # Current stage of the indexer 23 | enum IndexerStage { 24 | bootstrap 25 | replicate 26 | watch 27 | } 28 | 29 | # Status of the indexer process 30 | type IndexerStatus { 31 | # Last npm sequence indexed 32 | seq: Int 33 | # Is bootstrap sequence completed 34 | bootstrapDone: Boolean 35 | # Last package indexed in bootstrap 36 | bootstrapLastId: String 37 | # Date last finished bootstrap 38 | bootstrapLastDone: String 39 | # stage 40 | stage: IndexerStage 41 | } 42 | 43 | # status of the npm api 44 | type NpmStatus { 45 | # current npm sequence 46 | seq: Int 47 | # number of packages in the registry 48 | nbDocs: Int 49 | } 50 | 51 | type BuildJobs { 52 | main: Int 53 | bootstrap: Int 54 | } 55 | 56 | # Status on the whole application 57 | type ApplicationStatus { 58 | building: BuildJobs 59 | nbRecords: Int 60 | dataSize: Int 61 | fileSize: Int 62 | nbIndexes: Int 63 | nbBuildingIndexes: Int 64 | oldestJob: Int 65 | todayOperations: Int 66 | yesterdayOperations: Int 67 | todayIndexingOperations: Int 68 | yesterdayIndexingOperations: Int 69 | } 70 | 71 | type Query { 72 | applicationStatus( 73 | mainIndexName: String 74 | bootstrapIndexName: String 75 | ): ApplicationStatus 76 | indexStatus( 77 | mainIndexName: String 78 | bootstrapIndexName: String 79 | ): IndexesStatus 80 | indexerStatus(mainIndexName: String): IndexerStatus 81 | npmStatus: NpmStatus 82 | } 83 | `; 84 | 85 | const resolvers = { 86 | Query: { 87 | applicationStatus( 88 | _parent, 89 | { 90 | mainIndexName = 'npm-search', 91 | bootstrapIndexName = 'npm-search-bootstrap', 92 | } 93 | ) { 94 | return searchClient 95 | ._jsonRequest({ 96 | method: 'GET', 97 | url: '/1/indexes/*/stats', 98 | hostType: 'write', 99 | }) 100 | .then(({ building, ...otherKeys }) => ({ 101 | building: { 102 | main: building[mainIndexName] || 0, 103 | bootstrap: building[bootstrapIndexName] || 0, 104 | }, 105 | ...otherKeys, 106 | })); 107 | }, 108 | indexStatus( 109 | _parent, 110 | { 111 | mainIndexName = 'npm-search', 112 | bootstrapIndexName = 'npm-search-bootstrap', 113 | } 114 | ) { 115 | return searchClient 116 | .search([ 117 | { 118 | indexName: mainIndexName, 119 | params: { 120 | hitsPerPage: 1, 121 | attributesToRetrieve: [], 122 | attributesToHighlight: [], 123 | }, 124 | }, 125 | { 126 | indexName: bootstrapIndexName, 127 | params: { 128 | hitsPerPage: 1, 129 | attributesToRetrieve: [], 130 | attributesToHighlight: [], 131 | }, 132 | }, 133 | ]) 134 | .then(({ results: [main, bootstrap] }) => { 135 | return { main, bootstrap }; 136 | }); 137 | }, 138 | indexerStatus(_parent, { mainIndexName = 'npm-search' }) { 139 | return searchClient 140 | .initIndex(mainIndexName) 141 | .getSettings() 142 | .then( 143 | ({ 144 | userData: { 145 | seq, 146 | bootstrapDone, 147 | bootstrapLastId, 148 | bootstrapLastDone, 149 | stage, 150 | }, 151 | }) => ({ 152 | seq, 153 | bootstrapDone, 154 | bootstrapLastId, 155 | bootstrapLastDone: (bootstrapLastDone || 0).toString(), 156 | stage, 157 | }) 158 | ); 159 | }, 160 | npmStatus: () => 161 | got('https://replicate.npmjs.com', { json: true }).then( 162 | ({ body: { doc_count: nbDocs, update_seq: seq } }) => ({ 163 | nbDocs, 164 | seq, 165 | }) 166 | ), 167 | }, 168 | }; 169 | 170 | const ALL_ITEMS_QUERY = ` 171 | { 172 | applicationStatus( 173 | mainIndexName: "npm-search" 174 | bootstrapIndexName: "npm-search-bootstrap" 175 | ) { 176 | building { 177 | main 178 | bootstrap 179 | } 180 | } 181 | 182 | indexStatus( 183 | mainIndexName: "npm-search" 184 | bootstrapIndexName: "npm-search-bootstrap" 185 | ) { 186 | main { 187 | nbHits 188 | } 189 | bootstrap { 190 | nbHits 191 | } 192 | } 193 | 194 | indexerStatus(mainIndexName: "npm-search") { 195 | seq 196 | stage 197 | bootstrapLastDone 198 | bootstrapDone 199 | bootstrapLastId 200 | } 201 | 202 | npmStatus { 203 | seq 204 | nbDocs 205 | } 206 | } 207 | `; 208 | 209 | const server = new ApolloServer({ 210 | typeDefs, 211 | resolvers, 212 | introspection: true, 213 | playground: { 214 | settings: { 215 | 'editor.theme': 'light', 216 | }, 217 | endpoint: '/.netlify/functions/graphql', 218 | tabs: [ 219 | { 220 | endpoint: '/.netlify/functions/graphql', 221 | name: 'all items', 222 | query: ALL_ITEMS_QUERY, 223 | }, 224 | ], 225 | }, 226 | }); 227 | 228 | exports.handler = server.createHandler(); 229 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useQuery } from 'graphql-hooks'; 3 | import ms from 'ms'; 4 | import './App.css'; 5 | 6 | const ALL_ITEMS_QUERY = ` 7 | query AllInformation($mainIndexName: String, $bootstrapIndexName: String) { 8 | applicationStatus( 9 | mainIndexName: $mainIndexName 10 | bootstrapIndexName: $bootstrapIndexName 11 | ) { 12 | building { 13 | main 14 | bootstrap 15 | } 16 | } 17 | 18 | indexStatus( 19 | mainIndexName: $mainIndexName 20 | bootstrapIndexName: $bootstrapIndexName 21 | ) { 22 | main { 23 | nbHits 24 | } 25 | bootstrap { 26 | nbHits 27 | } 28 | } 29 | 30 | indexerStatus(mainIndexName: $mainIndexName) { 31 | seq 32 | stage 33 | bootstrapLastDone 34 | bootstrapDone 35 | bootstrapLastId 36 | } 37 | 38 | npmStatus { 39 | seq 40 | nbDocs 41 | } 42 | } 43 | `; 44 | 45 | const Info = ({ as: Tag = 'span', className = '', ...props }) => ( 46 | 47 | 48 | 52 | 53 | 54 | ); 55 | 56 | export default function App() { 57 | const { 58 | data, 59 | error, 60 | loading, 61 | refetch, 62 | fetchError, 63 | httpError, 64 | graphQLErrors, 65 | } = useQuery(ALL_ITEMS_QUERY, { 66 | variables: Object.fromEntries(new URLSearchParams(window.location.search)), 67 | }); 68 | 69 | return ( 70 | <> 71 |
72 | 80 | 86 |
87 | 88 | {error && ( 89 | 94 | )} 95 | 96 | {!error && data && } 97 | 98 | ); 99 | } 100 | 101 | function Visualization({ data }) { 102 | const { 103 | applicationStatus: { building }, 104 | npmStatus, 105 | indexStatus: { main, bootstrap }, 106 | indexerStatus: { 107 | bootstrapLastId, 108 | stage, 109 | bootstrapLastDone: bootstrapLastDoneString, 110 | seq, 111 | }, 112 | } = data; 113 | 114 | const bootstrapLastDone = Number(bootstrapLastDoneString); 115 | const nextBootstrap = bootstrapLastDone + ms('2 weeks'); 116 | const timeSinceLastBootstrap = new Date().getTime() - bootstrapLastDone; 117 | 118 | return ( 119 |
120 |
121 | stage: {stage} 122 |
123 |
124 | {stage === 'bootstrap' && ( 125 | 143 | )} 144 | {(stage === 'watch' || stage === 'replicate') && ( 145 | 165 | )} 166 |
167 | 168 | 169 |
170 | Raw data 171 |

172 | If you are interested, there's also a{' '} 173 | GraphQL playground{' '} 174 | available for this data. 175 |

176 |
{JSON.stringify(data, null, 2)}
177 |
178 |
179 |
180 | ); 181 | } 182 | 183 | const BootstrapStage = ({ lastProcessed, progress, packages, jobs }) => ( 184 | <> 185 |
186 | 194 |
195 | 196 |
197 |
198 | bootstrap sequence 199 |
200 |
201 | {(progress.bootstrap || 0).toLocaleString('fr-FR')} 202 |
203 |
204 |
205 |
206 | sequence difference 207 | 208 |
209 |
{progress.diff}
210 |
211 |
212 |
213 | npm sequence 214 |
215 |
216 | {(progress.npm || 0).toLocaleString('fr-FR')} 217 |
218 |
219 | 220 |
221 |
222 | # packages in bootstrap 223 |
224 |
225 | {(packages.bootstrap || 0).toLocaleString('fr-FR')} 226 |
227 |
228 |
229 |
230 | # packages difference 231 | 232 |
233 |
234 | {(packages.diff || 0).toLocaleString('fr-FR')} 235 |
236 |
237 |
238 |
239 | # packages in npm 240 |
241 |
242 | {(packages.npm || 0).toLocaleString('fr-FR')} 243 |
244 |
245 | 246 |
247 |
248 | jobs processing 249 | 250 |
251 |
252 | {(jobs.processing || 0).toLocaleString('fr-FR')} 253 |
254 |
255 | 256 |
257 |
258 | last processed 259 | 260 |
261 |
262 | {lastProcessed.id} 263 |
264 |
265 | 266 | ); 267 | 268 | const dayFormat = new Intl.DateTimeFormat('en-FR', { 269 | weekday: 'long', 270 | month: 'long', 271 | day: 'numeric', 272 | hour: 'numeric', 273 | minute: 'numeric', 274 | }); 275 | 276 | const WatchStage = ({ bootstrap, sequence, packages, jobs }) => ( 277 | <> 278 |
279 |
last bootstrap
280 |
281 | {(bootstrap.last || 0).toLocaleDateString('nl-BE')} 282 |
283 |
{(bootstrap.last || 0).toLocaleTimeString('nl-BE')}
284 |
285 |
286 |
287 | time ago 288 | 289 |
290 |
{bootstrap.diff}
291 |
292 |
293 |
next bootstrap
294 |
295 | {(bootstrap.next || 0).toLocaleDateString('nl-BE')} 296 |
297 |
{(bootstrap.next || 0).toLocaleTimeString('nl-BE')}
298 |
299 | 300 |
301 |
302 | npm-search sequence 303 |
304 |
305 | {(sequence.main || 0).toLocaleString('fr-FR')} 306 |
307 |
308 |
309 |
310 | sequence difference 311 | 312 |
313 |
314 | {(sequence.diff || 0).toLocaleString('fr-FR')} 315 |
316 |
317 |
318 |
319 | npm sequence 320 |
321 |
322 | {(sequence.npm || 0).toLocaleString('fr-FR')} 323 |
324 |
325 | 326 |
327 |
328 | # packages in npm-search 329 |
330 |
331 | {(packages.main || 0).toLocaleString('fr-FR')} 332 |
333 |
334 |
335 |
336 | # packages difference 337 | 338 |
339 |
340 | {(packages.diff || 0).toLocaleString('fr-FR')} 341 |
342 |
343 |
344 |
345 | # packages in npm 346 |
347 |
348 | {(packages.npm || 0).toLocaleString('fr-FR')} 349 |
350 |
351 | 352 |
353 |
354 |
355 | jobs processing 356 | 357 |
358 |
359 | {(jobs.processing || 0).toLocaleString('fr-FR')} 360 |
361 |
362 |
363 | 364 | ); 365 | 366 | function Error({ httpError, fetchError, graphQLErrors }) { 367 | if (httpError) { 368 | return
{httpError.statusText}
; 369 | } 370 | if (graphQLErrors) { 371 | return ( 372 |
373 | GraphQL Errors: 374 | {graphQLErrors.map(error => ( 375 | 376 | ))} 377 |
378 | ); 379 | } 380 | 381 | return ( 382 |
383 | A network error occurred: 384 | 385 |
386 | ); 387 | } 388 | 389 | function ErrorMessage({ message, ...more }) { 390 | return ( 391 |
392 | {message} 393 |
394 | Full error 395 |
{JSON.stringify(more, null, 2)}
396 |
397 |
398 | ); 399 | } 400 | --------------------------------------------------------------------------------