[{"data":1,"prerenderedAt":1155},["ShallowReactive",2],{"\u002Fblog\u002Fconnect-postgresql":3,"github-stars":1154},{"id":4,"title":5,"author":6,"body":9,"date":1134,"description":1135,"extension":1136,"image":1137,"imageAlt":1138,"imageDark":1139,"meta":1140,"navigation":127,"path":1141,"robots":1142,"seo":1143,"sitemap":1144,"stem":1152,"updated":1142,"__hash__":1153},"blog_en\u002Fblog\u002Fconnect-postgresql.md","How to Securely Connect PostgreSQL to Oxynote for Live Charts",{"name":7,"url":8},"Dovydas Bykovas","https:\u002F\u002Fgithub.com\u002Fdavseby",{"type":10,"value":11,"toc":1117},"minimark",[12,16,27,30,35,56,67,71,77,220,223,229,336,341,355,358,374,377,381,392,434,438,445,449,455,533,551,557,561,577,618,625,663,672,681,687,695,699,710,812,831,845,853,856,860,863,867,870,878,881,884,976,980,987,995,998,1002,1005,1061,1064,1072,1076,1079,1082,1093,1113],[13,14,15],"p",{},"Oxynote charts can query your PostgreSQL database directly, so a page can show live numbers from your app's own tables. To keep that safe, Oxynote only accepts an account that can't write.",[13,17,18,19,26],{},"This is about your database as a data source for charts. Oxynote's own data, its pages and settings, lives in a separate PostgreSQL inside the container. To move that to an external database instead, see the ",[20,21,25],"a",{"href":22,"rel":23},"https:\u002F\u002Fgithub.com\u002Foxynote\u002Foxynote\u002Fblob\u002Fmain\u002Fdocker\u002Fprod\u002FREADME.md",[24],"nofollow","operator README",".",[13,28,29],{},"This guide covers creating a read-only role, connecting it to Oxynote and three example pages built on it.",[31,32,34],"h2",{"id":33},"what-you-need","What You Need",[36,37,38,46,49],"ul",{},[39,40,41,42,26],"li",{},"Oxynote, running. See ",[20,43,45],{"href":44},"\u002Fblog\u002Fself-host-oxynote","the install guide",[39,47,48],{},"A PostgreSQL database that Oxynote's container can reach over the network.",[39,50,51,55],{},[52,53,54],"code",{},"psql"," access as a superuser or as the database owner, to create a role.",[13,57,58,59,62,63,66],{},"The examples use a database called ",[52,60,61],{},"bookings",", whose tables belong to ",[52,64,65],{},"booking_app",", the role its application runs as. Swap in your own names.",[31,68,70],{"id":69},"create-a-read-only-role","Create a Read-Only Role",[13,72,73,74,76],{},"Start with the role and its defaults. Run this in ",[52,75,54],{}," as a superuser:",[78,79,84],"pre",{"className":80,"code":81,"language":82,"meta":83,"style":83},"language-sql shiki shiki-themes github-light github-dark-default","-- the role Oxynote signs in as\nCREATE ROLE oxynote_reader LOGIN PASSWORD 'change-me';\n\n-- every transaction this role starts is read-only\nALTER ROLE oxynote_reader SET default_transaction_read_only = on;\n\n-- stop a slow query before Oxynote gives up on it\nALTER ROLE oxynote_reader SET statement_timeout = '5s';\n\nGRANT CONNECT ON DATABASE bookings TO oxynote_reader;\n","sql","",[52,85,86,95,122,129,135,159,164,170,191,196],{"__ignoreMap":83},[87,88,91],"span",{"class":89,"line":90},"line",1,[87,92,94],{"class":93},"sPyeA","-- the role Oxynote signs in as\n",[87,96,98,102,105,109,112,115,119],{"class":89,"line":97},2,[87,99,101],{"class":100},"sDgYj","CREATE",[87,103,104],{"class":100}," ROLE",[87,106,108],{"class":107},"szmH1"," oxynote_reader ",[87,110,111],{"class":100},"LOGIN",[87,113,114],{"class":100}," PASSWORD",[87,116,118],{"class":117},"sNbzA"," 'change-me'",[87,120,121],{"class":107},";\n",[87,123,125],{"class":89,"line":124},3,[87,126,128],{"emptyLinePlaceholder":127},true,"\n",[87,130,132],{"class":89,"line":131},4,[87,133,134],{"class":93},"-- every transaction this role starts is read-only\n",[87,136,138,141,143,145,148,151,154,157],{"class":89,"line":137},5,[87,139,140],{"class":100},"ALTER",[87,142,104],{"class":100},[87,144,108],{"class":107},[87,146,147],{"class":100},"SET",[87,149,150],{"class":107}," default_transaction_read_only ",[87,152,153],{"class":100},"=",[87,155,156],{"class":100}," on",[87,158,121],{"class":107},[87,160,162],{"class":89,"line":161},6,[87,163,128],{"emptyLinePlaceholder":127},[87,165,167],{"class":89,"line":166},7,[87,168,169],{"class":93},"-- stop a slow query before Oxynote gives up on it\n",[87,171,173,175,177,179,181,184,186,189],{"class":89,"line":172},8,[87,174,140],{"class":100},[87,176,104],{"class":100},[87,178,108],{"class":107},[87,180,147],{"class":100},[87,182,183],{"class":107}," statement_timeout ",[87,185,153],{"class":100},[87,187,188],{"class":117}," '5s'",[87,190,121],{"class":107},[87,192,194],{"class":89,"line":193},9,[87,195,128],{"emptyLinePlaceholder":127},[87,197,199,202,205,208,211,214,217],{"class":89,"line":198},10,[87,200,201],{"class":100},"GRANT",[87,203,204],{"class":100}," CONNECT",[87,206,207],{"class":100}," ON",[87,209,210],{"class":100}," DATABASE",[87,212,213],{"class":107}," bookings ",[87,215,216],{"class":100},"TO",[87,218,219],{"class":107}," oxynote_reader;\n",[13,221,222],{},"Oxynote stops a query after 10 seconds anyway. A lower timeout on the role makes a slow chart fail sooner, so pick whatever suits your database.",[13,224,225,226,228],{},"Table permissions belong to each database, so connect to ",[52,227,61],{}," and let the role read its tables:",[78,230,232],{"className":80,"code":231,"language":82,"meta":83,"style":83},"\\c bookings\n\nGRANT USAGE ON SCHEMA public TO oxynote_reader;\nGRANT SELECT ON ALL TABLES IN SCHEMA public TO oxynote_reader;\n\n-- tables booking_app creates later are readable too\nALTER DEFAULT PRIVILEGES FOR ROLE booking_app IN SCHEMA public\n  GRANT SELECT ON TABLES TO oxynote_reader;\n",[52,233,234,239,243,263,286,290,295,320],{"__ignoreMap":83},[87,235,236],{"class":89,"line":90},[87,237,238],{"class":107},"\\c bookings\n",[87,240,241],{"class":89,"line":97},[87,242,128],{"emptyLinePlaceholder":127},[87,244,245,247,250,253,256,259,261],{"class":89,"line":124},[87,246,201],{"class":100},[87,248,249],{"class":107}," USAGE ",[87,251,252],{"class":100},"ON",[87,254,255],{"class":100}," SCHEMA",[87,257,258],{"class":107}," public ",[87,260,216],{"class":100},[87,262,219],{"class":107},[87,264,265,267,270,272,275,278,280,282,284],{"class":89,"line":131},[87,266,201],{"class":100},[87,268,269],{"class":100}," SELECT",[87,271,207],{"class":100},[87,273,274],{"class":107}," ALL TABLES ",[87,276,277],{"class":100},"IN",[87,279,255],{"class":100},[87,281,258],{"class":107},[87,283,216],{"class":100},[87,285,219],{"class":107},[87,287,288],{"class":89,"line":137},[87,289,128],{"emptyLinePlaceholder":127},[87,291,292],{"class":89,"line":161},[87,293,294],{"class":93},"-- tables booking_app creates later are readable too\n",[87,296,297,299,302,305,308,310,313,315,317],{"class":89,"line":166},[87,298,140],{"class":100},[87,300,301],{"class":100}," DEFAULT",[87,303,304],{"class":107}," PRIVILEGES ",[87,306,307],{"class":100},"FOR",[87,309,104],{"class":100},[87,311,312],{"class":107}," booking_app ",[87,314,277],{"class":100},[87,316,255],{"class":100},[87,318,319],{"class":107}," public\n",[87,321,322,325,327,329,332,334],{"class":89,"line":172},[87,323,324],{"class":100},"  GRANT",[87,326,269],{"class":100},[87,328,207],{"class":100},[87,330,331],{"class":107}," TABLES ",[87,333,216],{"class":100},[87,335,219],{"class":107},[337,338,340],"h3",{"id":339},"tables-created-later","Tables Created Later",[13,342,343,346,347,350,351,354],{},[52,344,345],{},"ALTER DEFAULT PRIVILEGES"," only covers tables created by the role named in ",[52,348,349],{},"FOR ROLE",". If your migrations run as another role, a superuser for example, name that one too. Otherwise Oxynote gets ",[52,352,353],{},"permission denied"," on the next new table.",[13,356,357],{},"On PostgreSQL 14 and newer, there is a shorter way:",[78,359,361],{"className":80,"code":360,"language":82,"meta":83,"style":83},"GRANT pg_read_all_data TO oxynote_reader;\n",[52,362,363],{"__ignoreMap":83},[87,364,365,367,370,372],{"class":89,"line":90},[87,366,201],{"class":100},[87,368,369],{"class":107}," pg_read_all_data ",[87,371,216],{"class":100},[87,373,219],{"class":107},[13,375,376],{},"It reads every table and view in every schema, including the ones anyone creates later. The downside is that it also reads tables like users and sessions. If a table holds personal data, keep the narrow grants and give Oxynote a view with only the columns your charts need.",[337,378,380],{"id":379},"postgresql-14-and-older","PostgreSQL 14 and Older",[13,382,383,384,387,388,391],{},"Up to PostgreSQL 14, every role could create tables in the ",[52,385,386],{},"public"," schema, ",[52,389,390],{},"oxynote_reader"," included. Revoke it from everyone, then grant it back to the role that runs your migrations:",[78,393,395],{"className":80,"code":394,"language":82,"meta":83,"style":83},"REVOKE CREATE ON SCHEMA public FROM PUBLIC;\nGRANT CREATE ON SCHEMA public TO booking_app;\n",[52,396,397,417],{"__ignoreMap":83},[87,398,399,402,405,407,409,411,414],{"class":89,"line":90},[87,400,401],{"class":100},"REVOKE",[87,403,404],{"class":100}," CREATE",[87,406,207],{"class":100},[87,408,255],{"class":100},[87,410,258],{"class":107},[87,412,413],{"class":100},"FROM",[87,415,416],{"class":107}," PUBLIC;\n",[87,418,419,421,423,425,427,429,431],{"class":89,"line":97},[87,420,201],{"class":100},[87,422,404],{"class":100},[87,424,207],{"class":100},[87,426,255],{"class":100},[87,428,258],{"class":107},[87,430,216],{"class":100},[87,432,433],{"class":107}," booking_app;\n",[337,435,437],{"id":436},"or-point-it-at-a-replica","Or Point It at a Replica",[13,439,440,441,444],{},"Oxynote also accepts a read replica. A replica refuses every write and keeps chart queries off your primary. If you have one, connect Oxynote to it, and still grant ",[52,442,443],{},"SELECT"," only on the tables your charts need.",[31,446,448],{"id":447},"why-the-grants-matter","Why the Grants Matter",[13,450,451,452,454],{},"When you connect a data source, only the grants can ensure that its account won't be able to write. The read-only setting can't, because it's only a default, and any session can change its own defaults. Here is ",[52,453,390],{}," turning the setting off and then trying to add a row, on PostgreSQL 17:",[78,456,458],{"className":80,"code":457,"language":82,"meta":83,"style":83},"SET default_transaction_read_only = off;\n-- SET\n\nINSERT INTO quotes (id, created_at, lane, status)\nVALUES (1000, now(), 'Rotterdam → Gdańsk', 'booked');\n-- ERROR:  permission denied for table quotes\n",[52,459,460,473,478,482,496,528],{"__ignoreMap":83},[87,461,462,464,466,468,471],{"class":89,"line":90},[87,463,147],{"class":100},[87,465,150],{"class":107},[87,467,153],{"class":100},[87,469,470],{"class":100}," off",[87,472,121],{"class":107},[87,474,475],{"class":89,"line":97},[87,476,477],{"class":93},"-- SET\n",[87,479,480],{"class":89,"line":124},[87,481,128],{"emptyLinePlaceholder":127},[87,483,484,487,490,493],{"class":89,"line":131},[87,485,486],{"class":100},"INSERT INTO",[87,488,489],{"class":107}," quotes (id, created_at, lane, ",[87,491,492],{"class":100},"status",[87,494,495],{"class":107},")\n",[87,497,498,501,504,508,511,514,517,520,522,525],{"class":89,"line":137},[87,499,500],{"class":100},"VALUES",[87,502,503],{"class":107}," (",[87,505,507],{"class":506},"sPgVT","1000",[87,509,510],{"class":107},", ",[87,512,513],{"class":100},"now",[87,515,516],{"class":107},"(), ",[87,518,519],{"class":117},"'Rotterdam → Gdańsk'",[87,521,510],{"class":107},[87,523,524],{"class":117},"'booked'",[87,526,527],{"class":107},");\n",[87,529,530],{"class":89,"line":161},[87,531,532],{"class":93},"-- ERROR:  permission denied for table quotes\n",[13,534,535,536,538,539,542,543,546,547,550],{},"The ",[52,537,147],{}," goes through, but the ",[52,540,541],{},"INSERT"," still fails, and so do ",[52,544,545],{},"TRUNCATE"," and ",[52,548,549],{},"DROP TABLE",". The grants stop them, not the setting.",[13,552,553,554,556],{},"Anyone who can edit a chart's query in your workspace can run that ",[52,555,147],{},", and so can any AI agent you connect over MCP. The grants are the boundary. Keep the setting anyway, as a second layer behind them.",[31,558,560],{"id":559},"connect-it-in-oxynote","Connect It in Oxynote",[13,562,563,564,568,569,572,573,576],{},"Open ",[565,566,567],"strong",{},"Settings",", go to ",[565,570,571],{},"External Data Sources"," and click ",[565,574,575],{},"Connect"," next to PostgreSQL. Then fill in the form:",[36,578,579,588,601,612],{},[39,580,581,584,585,26],{},[565,582,583],{},"Data Source Nickname",": the name pages show, such as ",[52,586,587],{},"Bookings",[39,589,590,593,594,597,598,26],{},[565,591,592],{},"Data Source URL",": ",[52,595,596],{},"postgresql:\u002F\u002F\u003Chost>:5432\u002Fbookings",". When the database is on another machine, add ",[52,599,600],{},"?sslmode=require",[39,602,603,546,606,593,609,611],{},[565,604,605],{},"Auth Username",[565,607,608],{},"Auth Password",[52,610,390],{}," and its password.",[39,613,614,617],{},[565,615,616],{},"CA Certificate",", optional: when your database's certificate is signed by your own CA, paste that CA here. Oxynote then trusts only it and always connects over TLS.",[13,619,620,621,624],{},"The host is where Oxynote's container finds the database, and ",[52,622,623],{},"localhost"," there means the container itself, not your machine:",[36,626,627,633,660],{},[39,628,629,630,26],{},"If PostgreSQL runs in Docker on the same network as Oxynote, such as in the same Compose file, use its service name. The screenshots below use ",[52,631,632],{},"postgres",[39,634,635,636,639,640,643,644,647,648,651,652,655,656,659],{},"If it runs on the same machine as Oxynote but outside Docker, use ",[52,637,638],{},"host.docker.internal",". Docker Desktop on Mac and Windows provides that name. On Linux, add ",[52,641,642],{},"--add-host=host.docker.internal:host-gateway"," to Oxynote's ",[52,645,646],{},"docker run",". PostgreSQL also has to accept connections from the container, which ",[52,649,650],{},"listen_addresses"," in ",[52,653,654],{},"postgresql.conf"," and the rules in ",[52,657,658],{},"pg_hba.conf"," control.",[39,661,662],{},"Anywhere else, use its hostname.",[13,664,665,668,669,671],{},[565,666,667],{},"Test & Save"," checks that the account can't write before anything is saved. Try it with ",[52,670,65],{}," instead, and Oxynote refuses it:",[13,673,674],{},[675,676],"img",{"alt":677,"src":678,"src-dark":679,"title":680},"Oxynote refusing a PostgreSQL user with write access","\u002Fimages\u002Fblog\u002Fpostgresql-write-access.webp","\u002Fimages\u002Fblog\u002Fpostgresql-write-access-dark.webp","booking_app can write, so Oxynote refuses it",[13,682,683,684,686],{},"With ",[52,685,390],{},", the test passes and the data source joins the list with a green tick:",[13,688,689],{},[675,690],{"alt":691,"src":692,"src-dark":693,"title":694},"The PostgreSQL data source connected in Oxynote's settings","\u002Fimages\u002Fblog\u002Fpostgresql-data-sources.webp","\u002Fimages\u002Fblog\u002Fpostgresql-data-sources-dark.webp","Bookings, connected through the read-only role",[31,696,698],{"id":697},"draw-the-first-chart","Draw the First Chart",[13,700,701,702,705,706,709],{},"On any page, type ",[52,703,704],{},"\u002F"," and choose ",[565,707,708],{},"Live Metrics",". Pick the data source, then write a query that returns a time column and one or more numbers:",[78,711,713],{"className":80,"code":712,"language":82,"meta":83,"style":83},"SELECT\n  $__timeGroupAlias(created_at, '1h'),\n  provider,\n  avg((status = 'failed')::int) * 100 AS failure_rate\nFROM payment_attempts\nWHERE $__timeFilter(created_at)\nGROUP BY 1, provider\nORDER BY 1\n",[52,714,715,720,731,739,776,783,791,804],{"__ignoreMap":83},[87,716,717],{"class":89,"line":90},[87,718,719],{"class":100},"SELECT\n",[87,721,722,725,728],{"class":89,"line":97},[87,723,724],{"class":107},"  $__timeGroupAlias(created_at, ",[87,726,727],{"class":117},"'1h'",[87,729,730],{"class":107},"),\n",[87,732,733,736],{"class":89,"line":124},[87,734,735],{"class":100},"  provider",[87,737,738],{"class":107},",\n",[87,740,741,744,747,749,752,755,758,761,764,767,770,773],{"class":89,"line":131},[87,742,743],{"class":506},"  avg",[87,745,746],{"class":107},"((",[87,748,492],{"class":100},[87,750,751],{"class":100}," =",[87,753,754],{"class":117}," 'failed'",[87,756,757],{"class":107},")::",[87,759,760],{"class":100},"int",[87,762,763],{"class":107},") ",[87,765,766],{"class":100},"*",[87,768,769],{"class":506}," 100",[87,771,772],{"class":100}," AS",[87,774,775],{"class":107}," failure_rate\n",[87,777,778,780],{"class":89,"line":137},[87,779,413],{"class":100},[87,781,782],{"class":107}," payment_attempts\n",[87,784,785,788],{"class":89,"line":161},[87,786,787],{"class":100},"WHERE",[87,789,790],{"class":107}," $__timeFilter(created_at)\n",[87,792,793,796,799,801],{"class":89,"line":166},[87,794,795],{"class":100},"GROUP BY",[87,797,798],{"class":506}," 1",[87,800,510],{"class":107},[87,802,803],{"class":100},"provider\n",[87,805,806,809],{"class":89,"line":172},[87,807,808],{"class":100},"ORDER BY",[87,810,811],{"class":506}," 1\n",[13,813,814,815,818,819,822,823,826,827,830],{},"The two ",[52,816,817],{},"$__"," macros handle the time range. ",[52,820,821],{},"$__timeFilter(created_at)"," keeps the rows inside the chart's time range, and ",[52,824,825],{},"$__timeGroupAlias(created_at, '1h')"," buckets them by hour into a column named ",[52,828,829],{},"time",". If you've used Grafana's PostgreSQL data source, they will look familiar, because they follow its names.",[13,832,535,833,836,837,840,841,844],{},[52,834,835],{},"provider"," column splits the result into one line per payment provider. Setting the ",[565,838,839],{},"Legend"," field to ",[52,842,843],{},"{{provider}}"," names each line after it.",[13,846,847],{},[675,848],{"alt":849,"src":850,"src-dark":851,"title":852},"The Live Metrics editor with a SQL query and its chart","\u002Fimages\u002Fblog\u002Fpostgresql-chart-editor.webp","\u002Fimages\u002Fblog\u002Fpostgresql-chart-editor-dark.webp","The query, the chart it draws and a threshold at 5%",[13,854,855],{},"The chart runs its query when someone opens the page and again at its refresh interval, every minute here.",[31,857,859],{"id":858},"three-pages-worth-building","Three Pages Worth Building",[13,861,862],{},"The screenshots come from a demo workspace for Port, a made-up freight booking platform. Its Postgres holds a month of fake quotes, payments, carrier webhooks and customs holds.",[337,864,866],{"id":865},"a-runbook-with-its-evidence","A Runbook With Its Evidence",[13,868,869],{},"Port's booking funnel page says what to do when bookings drop: check payment failures first, and if one provider stays above 5% for 15 minutes, switch checkout to the fallback provider. The payment failure chart sits right under that sentence, with the 5% line drawn in.",[13,871,872],{},[675,873],{"alt":874,"src":875,"src-dark":876,"title":877},"A runbook page with live booking and payment failure charts","\u002Fimages\u002Fblog\u002Fpostgresql-runbook.webp","\u002Fimages\u002Fblog\u002Fpostgresql-runbook-dark.webp","The runbook rule with the chart that checks it",[13,879,880],{},"Whoever is on call reads the rule and checks it in one place.",[13,882,883],{},"The chart beside it needs no exporter or extra instrumentation, as a Prometheus metric would. It counts rows the app writes anyway, and each numeric column becomes a series:",[78,885,887],{"className":80,"code":886,"language":82,"meta":83,"style":83},"SELECT\n  $__timeGroupAlias(created_at, '1h'),\n  count(*) AS quoted,\n  count(*) FILTER (WHERE status = 'booked') AS booked\nFROM quotes\nWHERE $__timeFilter(created_at)\nGROUP BY 1\nORDER BY 1\n",[52,888,889,893,901,919,951,958,964,970],{"__ignoreMap":83},[87,890,891],{"class":89,"line":90},[87,892,719],{"class":100},[87,894,895,897,899],{"class":89,"line":97},[87,896,724],{"class":107},[87,898,727],{"class":117},[87,900,730],{"class":107},[87,902,903,906,909,911,913,916],{"class":89,"line":124},[87,904,905],{"class":506},"  count",[87,907,908],{"class":107},"(",[87,910,766],{"class":100},[87,912,763],{"class":107},[87,914,915],{"class":100},"AS",[87,917,918],{"class":107}," quoted,\n",[87,920,921,923,925,927,929,932,934,936,939,941,944,946,948],{"class":89,"line":131},[87,922,905],{"class":506},[87,924,908],{"class":107},[87,926,766],{"class":100},[87,928,763],{"class":107},[87,930,931],{"class":100},"FILTER",[87,933,503],{"class":107},[87,935,787],{"class":100},[87,937,938],{"class":100}," status",[87,940,751],{"class":100},[87,942,943],{"class":117}," 'booked'",[87,945,763],{"class":107},[87,947,915],{"class":100},[87,949,950],{"class":107}," booked\n",[87,952,953,955],{"class":89,"line":137},[87,954,413],{"class":100},[87,956,957],{"class":107}," quotes\n",[87,959,960,962],{"class":89,"line":161},[87,961,787],{"class":100},[87,963,790],{"class":107},[87,965,966,968],{"class":89,"line":166},[87,967,795],{"class":100},[87,969,811],{"class":506},[87,971,972,974],{"class":89,"line":172},[87,973,808],{"class":100},[87,975,811],{"class":506},[337,977,979],{"id":978},"a-page-support-can-read","A Page Support Can Read",[13,981,982,983,986],{},"Carriers report each container move to Port with a webhook, and some report faster than others. The carrier delays page charts the 95th percentile delay per carrier over the last week, using ",[52,984,985],{},"percentile_cont(0.95) WITHIN GROUP (ORDER BY delay_ms)",". An average would let a few very slow webhooks hide among the fast ones.",[13,988,989],{},[675,990],{"alt":991,"src":992,"src-dark":993,"title":994},"A page with webhook delay per carrier over seven days","\u002Fimages\u002Fblog\u002Fpostgresql-carrier-delays.webp","\u002Fimages\u002Fblog\u002Fpostgresql-carrier-delays-dark.webp","MSC sits at about 8 seconds, every day",[13,996,997],{},"MSC runs at about 8 seconds, nearly twice the others, and has all month. The page explains that this is normal and says what to do above 15 seconds, so support can answer \"is this carrier late?\" without asking an engineer.",[337,999,1001],{"id":1000},"numbers-that-are-true-right-now","Numbers That Are True Right Now",[13,1003,1004],{},"Some charts only need the current value. The number of open customs holds is a count of rows that haven't closed yet, so the query stamps it with the current time:",[78,1006,1008],{"className":80,"code":1007,"language":82,"meta":83,"style":83},"SELECT now() AS time, count(*) AS open_holds\nFROM customs_holds\nWHERE closed_at IS NULL\n",[52,1009,1010,1041,1048],{"__ignoreMap":83},[87,1011,1012,1014,1017,1020,1022,1025,1027,1030,1032,1034,1036,1038],{"class":89,"line":90},[87,1013,443],{"class":100},[87,1015,1016],{"class":100}," now",[87,1018,1019],{"class":107},"() ",[87,1021,915],{"class":100},[87,1023,1024],{"class":100}," time",[87,1026,510],{"class":107},[87,1028,1029],{"class":506},"count",[87,1031,908],{"class":107},[87,1033,766],{"class":100},[87,1035,763],{"class":107},[87,1037,915],{"class":100},[87,1039,1040],{"class":107}," open_holds\n",[87,1042,1043,1045],{"class":89,"line":97},[87,1044,413],{"class":100},[87,1046,1047],{"class":107}," customs_holds\n",[87,1049,1050,1052,1055,1058],{"class":89,"line":124},[87,1051,787],{"class":100},[87,1053,1054],{"class":107}," closed_at ",[87,1056,1057],{"class":100},"IS",[87,1059,1060],{"class":100}," NULL\n",[13,1062,1063],{},"As a gauge, orange at 20 and red at 30, it tells the customs team whether the queue is normal before anyone opens the admin panel.",[13,1065,1066],{},[675,1067],{"alt":1068,"src":1069,"src-dark":1070,"title":1071},"Gauges for quote conversion, open customs holds and webhook delay","\u002Fimages\u002Fblog\u002Fpostgresql-gauges.webp","\u002Fimages\u002Fblog\u002Fpostgresql-gauges-dark.webp","Current values as gauges",[31,1073,1075],{"id":1074},"wrapping-up","Wrapping Up",[13,1077,1078],{},"The grants are what keep your data safe, so review them the way you'd review a migration.",[13,1080,1081],{},"MySQL and MariaDB connect the same way, and their grants matter just as much.",[13,1083,1084,1085,1088,1089,1092],{},"Queries can also use variables: write ",[52,1086,1087],{},"WHERE carrier = '$carrier'",", and each reader picks the carrier in the ",[565,1090,1091],{},"Variables"," panel, so one chart covers every carrier.",[13,1094,1095,1096,1101,1102,1107,1108,26],{},"Oxynote is open source and runs as one Docker container: ",[20,1097,1100],{"href":1098,"rel":1099},"https:\u002F\u002Fgithub.com\u002Foxynote\u002Foxynote",[24],"github.com\u002Foxynote\u002Foxynote",". If a step here doesn't match your setup, we'd like to hear about it: ask on ",[20,1103,1106],{"href":1104,"rel":1105},"https:\u002F\u002Fdiscord.gg\u002FmNJd5zJkvC",[24],"Discord"," or ",[20,1109,1112],{"href":1110,"rel":1111},"https:\u002F\u002Fgithub.com\u002Foxynote\u002Foxynote\u002Fissues",[24],"open an issue",[1114,1115,1116],"style",{},"html pre.shiki code .sPyeA, html code.shiki .sPyeA{--shiki-default:#6A737D;--shiki-dark:#8B949E}html pre.shiki code .sDgYj, html code.shiki .sDgYj{--shiki-default:#D73A49;--shiki-dark:#FF7B72}html pre.shiki code .szmH1, html code.shiki .szmH1{--shiki-default:#24292E;--shiki-dark:#E6EDF3}html pre.shiki code .sNbzA, html code.shiki .sNbzA{--shiki-default:#032F62;--shiki-dark:#A5D6FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sPgVT, html code.shiki .sPgVT{--shiki-default:#005CC5;--shiki-dark:#79C0FF}",{"title":83,"searchDepth":97,"depth":97,"links":1118},[1119,1120,1125,1126,1127,1128,1133],{"id":33,"depth":97,"text":34},{"id":69,"depth":97,"text":70,"children":1121},[1122,1123,1124],{"id":339,"depth":124,"text":340},{"id":379,"depth":124,"text":380},{"id":436,"depth":124,"text":437},{"id":447,"depth":97,"text":448},{"id":559,"depth":97,"text":560},{"id":697,"depth":97,"text":698},{"id":858,"depth":97,"text":859,"children":1129},[1130,1131,1132],{"id":865,"depth":124,"text":866},{"id":978,"depth":124,"text":979},{"id":1000,"depth":124,"text":1001},{"id":1074,"depth":97,"text":1075},"2026-10-28","This guide sets up a read-only PostgreSQL role, connects it to Oxynote as a data source and builds live charts from your own tables.","md","\u002Fimages\u002Fblog\u002Fpostgresql-cover.webp","A runbook page in Oxynote with live booking and payment failure charts from PostgreSQL","\u002Fimages\u002Fblog\u002Fpostgresql-cover-dark.webp",{},"\u002Fblog\u002Fconnect-postgresql",null,{"title":5,"description":1135},{"loc":1141,"images":1145,"lastmod":1134},[1146,1147,1148,1149,1150,1151],{"loc":678},{"loc":692},{"loc":850},{"loc":875},{"loc":992},{"loc":1069},"blog\u002Fconnect-postgresql","0EL31oWxyUfLyBU8dTuxawTom6PVO0doLY0UbElCmmg",464,1793619147702]