1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 08:37:37 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-02 15:24:11 +02:00
parent 9f9d617730
commit 5e940690e9
35 changed files with 896 additions and 41 deletions

View File

@@ -101,7 +101,7 @@ Green **`master`** plus integration branch **`next`**. See [docs/GIT_FLOW.md](do
## PDF helpers
Generate the GraphVis decision memo PDF (from `tmp/GRAFANA_vs_others_graphvis_pivot.md` source):
Generate the GraphVis decision memo PDF (from `docs/GRAFANA_vs_others_graphvis_pivot.md` source):
```bash
bash scripts/build-graphvis-pdf.sh
@@ -109,4 +109,4 @@ bash scripts/build-graphvis-pdf.sh
Output:
`tmp/GRAFANA_vs_others_graphvis_pivot.pdf`
`docs/GRAFANA_vs_others_graphvis_pivot.pdf`

View File

@@ -45,7 +45,9 @@ public class CastSettings implements Serializable {
/** Future Speex (UI disabled until integrated). */
SPEEX,
/** Future Opus with in-band FEC (UI disabled until integrated). */
OPUS
OPUS,
/** Explicit AAC passthrough wrapper for debug comparisons. */
PASSTHROUGH_DEBUG
}
public enum AdjustmentPreset {

View File

@@ -604,6 +604,8 @@ public final class CastSettingsBinder {
return activity.getString(R.string.codec_speex);
case OPUS:
return activity.getString(R.string.codec_opus);
case PASSTHROUGH_DEBUG:
return activity.getString(R.string.codec_audio_passthrough_debug);
case AUTO:
default:
return activity.getString(R.string.codec_audio_auto);

View File

@@ -16,6 +16,7 @@ import com.foxx.androidcast.CastTuningEngine;
import com.foxx.androidcast.media.CodecNegotiator;
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
import com.foxx.androidcast.network.diag.DiagPingMetrics;
import com.foxx.androidcast.receiver.StreamMetrics;
@@ -393,9 +394,17 @@ public final class CastDiagnosticsFormatter {
if (prefs != null && prefs.isAudioRequested()) {
CastSettings.AudioCodec ac = prefs.getAudioCodec();
if (ac == CastSettings.AudioCodec.OPUS) {
html.append(" · <font color='").append(COLOR_OK).append("'>Opus in-band FEC</font>");
if (NativeCodecBridge.isOpusAvailable()) {
html.append(" · <font color='").append(COLOR_OK).append("'>Opus in-band FEC</font>");
} else {
html.append(" · <font color='").append(COLOR_WARN).append("'>Opus fallback AAC</font>");
}
} else if (ac == CastSettings.AudioCodec.SPEEX) {
html.append(" · <font color='").append(COLOR_OK).append("'>Speex FEC</font>");
if (NativeCodecBridge.isSpeexAvailable()) {
html.append(" · <font color='").append(COLOR_OK).append("'>Speex FEC</font>");
} else {
html.append(" · <font color='").append(COLOR_WARN).append("'>Speex fallback AAC</font>");
}
} else {
html.append(" · AAC-LC (MediaCodec)");
}

View File

@@ -12,6 +12,7 @@ package com.foxx.androidcast.diagnostics;
* Digest: SHA256 1c6f25ad871cb71788ed6fd1817d4d59d22d5ef6638704c9483937a2a9c15977
**********************************************************************/
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
/** Human-readable names for diagnostics (no bare AUTO enum tokens). */
public final class CastDiagnosticsLabels {
@@ -137,9 +138,13 @@ public final class CastDiagnosticsLabels {
case AAC:
return "AAC-LC (forced)";
case SPEEX:
return "Speex (stub → AAC)";
return NativeCodecBridge.isSpeexAvailable()
? "Speex (native)" : "Speex (fallback AAC)";
case OPUS:
return "Opus + in-band FEC (stub → AAC)";
return NativeCodecBridge.isOpusAvailable()
? "Opus + in-band FEC (native)" : "Opus (fallback AAC)";
case PASSTHROUGH_DEBUG:
return "Passthrough AAC (debug)";
case AUTO:
default:
return "AUTO (AAC via MediaCodec)";

View File

@@ -248,10 +248,13 @@ public final class CodecPriorityCatalog {
public static CodecTier tierForAudioPreference(CastSettings.AudioCodec preference) {
if (preference == CastSettings.AudioCodec.SPEEX) {
return CodecTier.PASSTHROUGH;
return NativeCodecBridge.isSpeexAvailable() ? CodecTier.SOFTWARE : CodecTier.PASSTHROUGH;
}
if (preference == CastSettings.AudioCodec.OPUS) {
return CodecTier.SOFTWARE;
return NativeCodecBridge.isOpusAvailable() ? CodecTier.SOFTWARE : CodecTier.PASSTHROUGH;
}
if (preference == CastSettings.AudioCodec.PASSTHROUGH_DEBUG) {
return CodecTier.PASSTHROUGH;
}
return CodecTier.HARDWARE;
}

View File

@@ -84,6 +84,8 @@ public enum CodecPriorityId {
return OPUS;
case SPEEX:
return SPEEX;
case PASSTHROUGH_DEBUG:
return PCM;
case AUTO:
default:
return null;

View File

@@ -14,8 +14,11 @@ package com.foxx.androidcast.media.codec;
/** Implementation backing negotiated / configured audio. */
public enum AudioCodecBackend {
MEDIA_CODEC_AAC("MediaCodec AAC-LC"),
SPEEX_NATIVE("Speex native"),
OPUS_NATIVE("Opus native"),
SPEEX_STUB("Speex (stub → AAC passthrough)"),
OPUS_STUB("Opus (stub → AAC passthrough)"),
PASSTHROUGH_DEBUG("Passthrough AAC (debug)"),
DISABLED("off"),
UNKNOWN("?");

View File

@@ -21,7 +21,9 @@ public final class AudioCodecFactory {
public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference) {
CodecSessionRegistry.setAudioPreference(preference);
AudioCodecBackend backend = PassthroughCodecPolicy.audioBackendFor(preference, true);
if (backend == AudioCodecBackend.SPEEX_STUB || backend == AudioCodecBackend.OPUS_STUB) {
if (backend == AudioCodecBackend.SPEEX_STUB
|| backend == AudioCodecBackend.OPUS_STUB
|| backend == AudioCodecBackend.PASSTHROUGH_DEBUG) {
return new PassthroughAudioEncoderSink(new AudioEncoder(), preference);
}
return new AudioEncoder();

View File

@@ -31,6 +31,8 @@ final class PassthroughAudioEncoderSink implements AudioEncoderSink {
CodecSessionRegistry.setAudioBackend(AudioCodecBackend.OPUS_STUB);
} else if (preference == CastSettings.AudioCodec.SPEEX) {
CodecSessionRegistry.setAudioBackend(AudioCodecBackend.SPEEX_STUB);
} else if (preference == CastSettings.AudioCodec.PASSTHROUGH_DEBUG) {
CodecSessionRegistry.setAudioBackend(AudioCodecBackend.PASSTHROUGH_DEBUG);
}
delegate.prepare(callback);
}

View File

@@ -57,7 +57,12 @@ public final class PassthroughCodecPolicy {
}
switch (preference) {
case SPEEX:
return NativeCodecBridge.isSpeexAvailable()
? CastSettings.AudioCodec.SPEEX : CastSettings.AudioCodec.AUTO;
case OPUS:
return NativeCodecBridge.isOpusAvailable()
? CastSettings.AudioCodec.OPUS : CastSettings.AudioCodec.AUTO;
case PASSTHROUGH_DEBUG:
return CastSettings.AudioCodec.AUTO;
default:
return preference;
@@ -70,10 +75,15 @@ public final class PassthroughCodecPolicy {
return AudioCodecBackend.DISABLED;
}
if (preference == CastSettings.AudioCodec.SPEEX) {
return AudioCodecBackend.SPEEX_STUB;
return NativeCodecBridge.isSpeexAvailable()
? AudioCodecBackend.SPEEX_NATIVE : AudioCodecBackend.SPEEX_STUB;
}
if (preference == CastSettings.AudioCodec.OPUS) {
return AudioCodecBackend.OPUS_STUB;
return NativeCodecBridge.isOpusAvailable()
? AudioCodecBackend.OPUS_NATIVE : AudioCodecBackend.OPUS_STUB;
}
if (preference == CastSettings.AudioCodec.PASSTHROUGH_DEBUG) {
return AudioCodecBackend.PASSTHROUGH_DEBUG;
}
return AudioCodecBackend.MEDIA_CODEC_AAC;
}
@@ -150,6 +160,8 @@ public final class PassthroughCodecPolicy {
return "Opus";
case AAC:
return "AAC";
case PASSTHROUGH_DEBUG:
return "Passthrough (debug)";
case AUTO:
default:
return "AUTO (AAC)";

View File

@@ -13,6 +13,7 @@ package com.foxx.androidcast.network;
**********************************************************************/
import android.media.MediaFormat;
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
import com.foxx.androidcast.sender.CodecCatalog;
import java.util.List;
@@ -28,6 +29,8 @@ public final class CastCodecFlags {
public static final int AUDIO_UNKNOWN = 0;
public static final int AUDIO_PCM = 1;
public static final int AUDIO_AAC = 2;
public static final int AUDIO_OPUS = 4;
public static final int AUDIO_SPEEX = 8;
private CastCodecFlags() {}
@@ -59,6 +62,13 @@ public final class CastCodecFlags {
}
public static int localAudioMask() {
return AUDIO_AAC | AUDIO_PCM;
int mask = AUDIO_AAC | AUDIO_PCM;
if (NativeCodecBridge.isOpusAvailable()) {
mask |= AUDIO_OPUS;
}
if (NativeCodecBridge.isSpeexAvailable()) {
mask |= AUDIO_SPEEX;
}
return mask;
}
}

View File

@@ -103,6 +103,7 @@ public final class CastProtocol {
dos.writeInt(settings.getCaptureMode().ordinal());
dos.writeInt(settings.getStreamProtection().ordinal());
dos.writeInt(settings.getReceiverDisplayResolution().ordinal());
dos.writeInt(settings.getAudioCodec().ordinal());
dos.flush();
return bos.toByteArray();
}
@@ -147,6 +148,10 @@ public final class CastProtocol {
readEnum(CastSettings.ReceiverDisplayResolution.values(), dis.readInt(),
CastSettings.ReceiverDisplayResolution.AUTO_FIT));
}
if (dis.available() > 0) {
settings.setAudioCodec(readEnum(CastSettings.AudioCodec.values(), dis.readInt(),
CastSettings.AudioCodec.AUTO));
}
} else {
dis.reset();
settings.setTransport(dis.readUTF());

View File

@@ -88,10 +88,13 @@ public final class CodecCatalog {
public static List<AudioCodecChoice> audioCodecChoicesForUi(CastSettings settings) {
List<AudioCodecChoice> out = new ArrayList<>();
boolean opus = NativeCodecBridge.isOpusAvailable();
boolean speex = NativeCodecBridge.isSpeexAvailable();
out.add(new AudioCodecChoice(CastSettings.AudioCodec.AUTO, true, 0));
out.add(scoredAudio(CastSettings.AudioCodec.AAC, true, settings));
out.add(scoredAudio(CastSettings.AudioCodec.OPUS, true, settings));
out.add(scoredAudio(CastSettings.AudioCodec.SPEEX, true, settings));
out.add(scoredAudio(CastSettings.AudioCodec.OPUS, opus, settings));
out.add(scoredAudio(CastSettings.AudioCodec.SPEEX, speex, settings));
out.add(scoredAudio(CastSettings.AudioCodec.PASSTHROUGH_DEBUG, true, settings));
sortAudioChoices(out);
return out;
}

View File

@@ -226,12 +226,13 @@
<string name="dev_reload_codecs_json">Перезагрузить codecs.json</string>
<string name="dev_reload_codecs_json_hint">Подгружает overrides из assets/codecs.json без перезапуска. Откройте настройки снова, чтобы обновить списки.</string>
<string name="codec_priority_score_suffix"> · %1$d</string>
<string name="label_audio_codec">Кодек для звука</string>
<string name="label_audio_codec">Аудио кодек</string>
<string name="codec_libvpx_vp8">libvpx (VP8)</string>
<string name="codec_libvpx_vp9">libvpx (VP9)</string>
<string name="codec_aac">AAC</string>
<string name="codec_speex">Speex</string>
<string name="codec_opus">Opus</string>
<string name="codec_audio_passthrough_debug">Passthrough (отладка)</string>
<string name="codec_audio_auto">AUTO (AAC)</string>
<string name="pip_enter">Картинка в картинке (PiP)</string>
<string name="pip_unavailable">Режим «картинка в картинке» (PiP) недоступен на этом устройстве</string>

View File

@@ -318,12 +318,13 @@
<string name="ota_download_failed">Download failed: %1$s</string>
<string name="ota_notification_channel_name">App updates</string>
<string name="codec_priority_score_suffix"> · %1$d</string>
<string name="label_audio_codec">Voice codec</string>
<string name="label_audio_codec">Audio codec</string>
<string name="codec_libvpx_vp8">libvpx (VP8)</string>
<string name="codec_libvpx_vp9">libvpx (VP9)</string>
<string name="codec_aac">AAC</string>
<string name="codec_speex">Speex</string>
<string name="codec_opus">Opus</string>
<string name="codec_audio_passthrough_debug">Passthrough (debug)</string>
<string name="codec_audio_auto">AUTO (AAC)</string>
<string name="pip_enter">Picture-in-picture</string>
<string name="pip_unavailable">Picture-in-picture is not available on this device</string>

View File

@@ -79,4 +79,4 @@ _Assumptions: current MariaDB crash/ticket/session structures are available; no
## Source linkage
- This markdown file is the editable source for:
- `tmp/GRAFANA_vs_others_graphvis_pivot.pdf`
- `docs/GRAFANA_vs_others_graphvis_pivot.pdf`

View File

@@ -42,7 +42,7 @@ endobj
endobj
7 0 obj
<<
/Author (Android Cast project) /CreationDate (D:20260602143110+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260602143110+02'00') /Producer (ReportLab PDF Library - \(opensource\))
/Author (Android Cast project) /CreationDate (D:20260602144833+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260602144833+02'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (Grafana vs Others GraphVis Pivot) /Trapped /False
>>
endobj
@@ -53,17 +53,17 @@ endobj
endobj
9 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2837
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2776
>>
stream
Gb!Sm=``=W&q9SYkXT2.;7oK>:J0snZUsN9b1&P`.Ku#tKU>gH;RY`>DrVrcGt_/)h0mq?'?_X>5bHrcJ/kf`a-"-#*8pJkVJdJo>Z`PnnLYgu+5qTeo&I[9P!NX3Xr"Y\Ks;A;JWKDAfWo#=$TtZ+=_L;[=Ae#%HjAre$*NN[D/d<2[rB0?`>5U,2@(<RLa"4`nO3e[H'-Z(9Ap?=%G\I2"Rg)'Xl0f1>EZ8+8AiRO0%m+<?GPP^/:N5ni=`*Z(]8PNCZ"%$3j>/XkeQ6)a'&^?CBnIo01+pNF])94'muO$\qHDHXB=um[j8MZ7660tKTn:rd^I;#U,9#T75Iuk1"9c(-b"cX'M(NqD8R!(FgaE-]GF_b+3-_DI=X)V;!g5-8+$MONCb;U\JU@R\)Q&._#tip$Hq=G;=W,WfMBXlE^:cgJ*L2,nA=-K=k=a%GJGPnhl[HaX-7l`e0/!8M-uD$17G%d+k[NLgq^4J^bN-EOK>:*ZF1J6[WA%p/ljKmObO?^VBoO&%4=(5F.\a/+]O&3b+bR?c+p>_QrFg0QM/4K<Rr\Ek1AcMcRR.=\/D9iIL4Q9ROkKZs8#,u7a>>t]]`;12_+<o-EJ'46Haj2+fV_Cc5.S#Z?ial[aVBqn0tOrqS8P:hnH6skFugnG%aZ'HU\[g7<R`L04,$]=^jt>L-sD[VTe#ir?[ab?f.0F:3kaBC,'hS?!?)$^rs/]L*EY[1jLs,G;Xk3s#g=iiSVdi9l'`F,nP"4ppWl2S<`Q@U+#rsnc/=s.s<-Z=*jdioa[Z)mS0,4]:ojt)-k@YK7Uqlp7C368C'U(-ME5]h,&JQ'(6%AK7Ru#i#`C&]7E=:bk@&Q;uRKKk+P`B9-9NFZ<2YsKoa("#R$9&"F_??oSsbt6YHCYc:3ftk79C`)n)HUgH6kVFr9'6G^mL,K^k25`(#W/,XL0o]1s^jh)J8BEgLdu0QNX[.#VmLC^I1oKG<pNV=h9t?:\3nP6M2.7V+/`5XD3cf3^^6nkr&,o)e>_ZNdLAQ8ERRHN'eJAX"EWY[W;3QGW=f`KX:RC[&9>.AFgSELIT,)LbfR>lj\L$)GO_&KN93]4AueMD\.S=6\bCCCgcGH</nO)J[:\jR,YtEJsZ3eTc^GX>Ks8eg54?X9jtFAR[%d=L0N_F,nb0P['>_r<f?N%'B0pBY>-]l*jq$7]J\`D-</3(RG;gI3q+e@J$.dOZ=/:'/gS]KkPd8,kMf,8a8e0Vi3I$$Y(]mU3mA\-'T\n"pm.G!R?cYd!",`iI"C@)7(P+#Tlu+ZpV#UkC%8\"F_ud>mi9\[PE)p@_=7<'f$4+[O6G!%5]X9cY\Bu=llBUH"#i<ETGkl-[T!_4`o^FfQtljJ0!0]R)0RR6dH/oZn9VAG"'`eQIWf*:eC9))Ac-$>tWt49O[\'"i`_horVorP;SX,B(6_<11P]kjgU@h5%[%$W#f.Mg)#9?Ob.14?IA(G.kmD!`&^.$\m1PDK-cnm=?Y;HLapQP#SDQ:(-%\S)GK*9U\rWW#*q0FonFb,RM)Zbf"HL$n[R.^Sg&,rhWbl=pl$4o.9[cdWZNfR1%"G1FO0MiLPkrd1#R%7n$BlT"DnIY#-J,m+=-1W@dfYK/!^0,&er.U']CBO"lji&JVFr9BFo,=I#FR8#_q44;^k>9(-QAo:/e`Tqm*qgV.:VZD]SjVPnENY:D4<e\,`55Tfr@aEOBT9aF`89h%gGeBM<+dE4ndrPg?gWmj5n^5jB@/$tch@[Pb*n.5G'n\d2#-hIBU<<!8\ZcmqXg&:TKC5Rg!bdca52^o?$_8hm5A`Y2Cp"rU@*U\?Hf]/;CMV=fAjFFZ'Z3JN*Yh'k_Y6m.B;J]iMFna,Z@So$RLkI]%>70=Dt3g=++4ID[Ea&QF^_recDH\&Bu.#,u64OAM&4q\qM?lou-p8f553?IlF\D#akaC>@I-A)3@LpnmnBCdOLH^NK/]q#5>4u!G;4^BG?.kcjo*SKEn6O,qo$8';A%7?aP&:n9th"ZB;\6jMg72nt/jFoRL,0++6M16]2,3A;(oS4.4CRiYaFu,8B2_/]b&`_;?;5nkqBXr\3=Zj34a*?g1g]<!Ka-(C"GcDLo`t>=ti6CcG[30(E,394MWo`DHVT@OtVBIUq.ES%OJ<eplM'0fP7F27WC1P4#c+V)K`B?l5fgh-i1t,$o;*%1B`Vs00.Z[+s0l^'2M.eua=Nm\CTtbk-L<ke$:8[.*e'#'i;V$G5V)QI".<L*+E8:_T1-`o-3_?T#j:gdA<E@s#Y.qn/AU#\8=mu<GnOg7p-93-eYfi0cSZ4f>$L`qIWR3)4]A4"co_Ro]@mDNTj.\cT5hs`k5+M-lj1H*Ee<C^5Z)VrPM"s\c!3N<un<W8SV4&g^@1pK?@7N2XUEUh[VL'0>'%cZT..mL^/SkQ[BP%BrN_Om/c=OJd',PF+)$Cn47O1)2]dK(Ni%^HOPA3S-h+mtOS?fq*m]Q!g&DN.g(3cJp$9V['Q)7ZY=A4,Hl36I[XCo4:!hr,%%k`/:cZRJZM\7bf`:(H]lnaF2!AjC*IhE&G62KC1nP<elSe4ZUJJlI"4_$BiJq5@H*MeE,h3d2FMTq/=7F1HMiN]9D)=]GY*#r7a27@oH7p=EDC!dq_#&I1QKG2Bieg<8L]:X\A1:"fPWG.AUELojW?PN/R6uqO)GsgCNm%M/-FAo-A+*Ikt02g)Z#E8(ZL_LDCpVHRE002tr-bei8,CAT/eGL'.#B:8NXNWg_:Y%?MqC5G9$r]Umk'E^5GL9ZDl`%T5,oa]Vnb*Z`9<pIp2BsU,b??R-]3o7IdcQA/boj6"h[NgZK5gC"gpatO~>endstream
Gat=-gNV@2'7\>80opEDN%JCNSnO:1p\WYi:$A0sa>lE#(8-Yl?-=R's8!bXp]>6#b970(,L;>2qAWjnN\<cuoiD-cVD.j_J3dR(#K+t@R!sb$l,Bh6A%U3524^*E!S?iugU&P[%`t1-&^rX(1WPODHD%f,)-[<@M[ST[<+:sJ$:PL[4=Pjn.DRYpgN<rAl-fV:P:/t3*l&4")8$O?_O$K69DF^/S;-q`Rdi@h8%t"i%l!r$VSBst/dp2(420FU[]H@W[Vh$E"8:XH[C-IF%2]n)O\-.UrA\4-iToRZ,@!RBi6aX1W;k#7?jKU<!R3"qGnP/4coBtj$[K[FON&HURSgm&TFhVl)>AhcSu"r&e($"mo;(s%%?@Qag7A``0rkbs5)J?AGRhp%1s[ttE.u%UKDBH,<5,ef`cg)P:%>Pts*ad+lQ"u$]4^Uj]n:_&e%RC`]ZJ`_j?Kp:^j'+O:04(SLB/iV[pWUB0_ijp(j,W"+L\0%[W)f?5A?Lk4jM<ml[We5MPhO>d+AqT9='%l/LNQ21!ti!V;i*NAFEn@31950*+eJ"408#k]P3N7D6CXgCN6r$"TNQKP;'iFKpP)fm$EebPH/u7_Z.?tPgeA_0->KMBT[?G.'f*.k_M$d_>ZV&5K1ce2uPd8jU+Z7AlR>1>cj0N^%6'hF"1Xj/G:75P5L+LS\b/gq-;!M]4.i9UMLokXK\oEi9);B=7H"^,?AE]SAd;o7%^q`QAmWg)T6P+LcA3/,;SM*&OGcAd?gcQkK3\`k?@eBZR&8`c\:_HCG(&_Z70Oh[X.R1r;HLZo8%1Ukl(7R0A$]lX_J9F\rOpE#g\C(2[T?+a*M6*@nR%OCb-#&Y"AaRNs5Vd,lq4>K7L"cJ8LuG^f<tA<E&a=9/)<E@=G1\[4%=;hUQuo_piLS$LK_.C4ko6Wsr?0od]`HeaR3ZBQ3^kr&j"T"sREVB\K"sPs!6*b%lf!hQF^u"]/'H:7's4SRCqr\8IQO>elfW=i__G_@>upn-j[__69B*S$KCe^f0B.\D_!1Vt(.MW7(J:qc<rEMQhU+!,ju(!nJskBCPP-E`8Q@Yem-WUBQ7@!1of)7Y1r3XL",r=Afq=##\ZjL4@FV;?>ld!mQi\ej>,]Xq9-B@>SHf/6'O\,R92\+H52[$7Qdp9)9Ng\3YNneO5/=XjHtfQGt=bSl24I^ni<]YM#,(]L&QP&2'[0nft(Bb-fCCKB5RoCAV`0JO+j"B/pJ)C#V*=E#:&4XuUhBGfUV`L6H8-Yk$i%j2blbBMP".Kmpua/'[0a'=qR\#]V&RV'.l+26Ru>@UI0^ZWp&]$bB5"3s'A)(t+H4R%5sM=a%43[$W3Z4=G'3o.\*N*-G?C'&XDHk$$UbJEsY<f3Q1*2o2LgN^KOqDRDX8$ZSc,Q@i#`E/qb;]83_rBVu`gVWPFT:<qktR)[tLOr-.eNmU,TE0_UO7du)Afe($^FNlXbpFHWs._.RZRD07oNX+'gfdk]/gDY7'JhMbNOB#H<M\U"t)?"&teL'moS=BI'edG1G+XD'K`KF5g@'E[Q1mSLfZL+6oKR)2NW]^ZJ=Eoe>Fg"S,dr&+R\L@>"fc,,"3Le@T1qToKEgU@M6;T/tedBSA<d\9eDXnb0iC0[(9O(X&)iL_(l'hJXZFJVF"+/`\j4$A<Nisf2p6sFg>NrQZ89N3+Q=(Yi_64j=+m>I5gg8I<lYoc]^ro?Y)HRCRS)"HXg0Ju25QL0:W]QOf]b2od%X,$m(WRCCN#19QaOKtS!MhO<:b*T,O`;Hu(=@Hf)9dA^<"+>qiTReLVouPOQKD/,P#3n?fG`W+oG'($p+g6L8_6CK_2l@ROK9[(_@T*=)=,!KW+,*5pYrt(Yr>5F.-%@3DSQkk73WgpTPmfqF`=t2heO((^^kau69`FhYhg7qUHC)Q!cqM_'\nhU*5uj6\i-BYAf%Lg/`(2g!sNnO&N?s%9Hi2ja<e&O*)5VKl^fi4!Wa*$:Xfr8eOa/]2!S)?<(Jr!4WGj4B^lO1Bn[/Bd8L'aPe7%:DR=):OqS$fG@u5cbMrfFC1G;7fO,tU=d*CJPFt<N9p/hDQ&)c86R(1r4Eha(;I$W.`=[_:*gcM6;:A7o.XWE(eY8j`Nf4*b9%;RKSdEe;qJ"m@eDR=?6<;Q"NW9?)?3VT\TR4$Eel'BLZ-lE4I#"oL:@uGC=uR@HY_aEF@GH6$$N)Pe;fC]C!h.\ec.n0pV279NkRmJNJpA-p)qf7l\:qe&;)<<'SL*@bS]Z+TFK9g-a2;5e\,<Vi;Z..iFfdFS)2oZujVhSpS949cCt4?G7#<pa0VdB<`V*2U48D`G6H$NCi7O'\.EDn<>'tudE=J`_UO[7IAgMI$EBrO/Tu'B:QE!RgJc2:S#rr7K=K?kA-!mGL!ia*"5D/n[%$gYS0<O.FQAgROo!%N+rnG_!BK#B,\f<"1/W9QKTk'$UR*d=,j+4jQP))gMhZu\h0Z7QR1)bnTp!o<A,[B3[5@VMN4\R5uiWA6'_[u76O5&DmA53q7h@2>m67Pj5neGFTrp7O'`\@e2HTKY(6^5,Bemi?K\A=kE>Uc9s_K^fki"CZ"jE/DZ^2*\iCV<L/(dV%NCeOiS=t3]F7J;\9pP+d+kVJTCR].ue^AWPP0o29)"\rob!VXYkpu>D6\.H3p^FW,Vs$aLn29BKkNaZXb[XUef!iR9S=G5m.JspFa#H2>jP3m5o\JY"QpC>0E$0D^VZXi2ZgSg4pC/G?``$daH+AKBpl(*pTZS^8gh<e:fE:GT(>fAu~>endstream
endobj
10 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1169
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1790
>>
stream
Gat=)gN)=4%"7kOi%_jCBo/!$3IT%]]=k#H\bIm4N'ob_PphonS@W'7OKrCW'1^N3`qIJ5!"M9P0SBFud!t3n^src-^P!.r=KNa*8IVY(pp-@e2%00",UEJN(kn2c,`.[$rQlO/Zein[69#+m?r^W#MP49?8mSs5KYW5]l`(Sti)\D9>#bh;]=QOSf?.4?V6]4sXM2U;ZcdiZc[-W52W?D_NVR&GP)$aQ?W5l2oK;+!.83[MWBo&M0Uo5'2A_bHG#>V@.<Ego^`Pm":b)PNfcd"pK@R)P\UZoTQMfkPcCpJ,!KZuaYa+4u&C9)CmrJ>,25S)l/T@YAeM9')>k/mNXm/u43qJAiMHbWd9DOVs^kdjulbH`H]gJpEh,!6b4_gc[fNO32:N,i1[B_>+Dp_h+h62$!E:`X*F'V&K2o<@6C1C[XCY6"!@hO@/37bGj_AIJ<k?uGU"3)Wb)pG4faK7epgLLh(0"/E/OZ@g>I*EB2^;_p_;Xm$B4EHF&q6P1NR@f$HF^ohc+P0F\(ERrW+0I6"U#M&-/CD-F>`n8J?qWsk(fN@#mfOCI!<6nB)1akT2mpI[.If?W97N\loGX58EKVJ<8@W25!_L=""A"X%.#EIB9,=<@O>NZS3-:CLo:l=]E(mPg>f%If[".g^%-jE*'>05H5%"e;J+m:&FC=flil9Ge-Yi536Xf5eL[LF?F?U*`:>H5I4^-6l)Eu$V!=?Ug9a9g@E8.AfF",u?LshMU;0E6EAT^""NX*PFa9V(;c9Ih,QM&2I&nRnHP="DLR(2#3&e%t'\$S"Z0AJlHGYq&of2@umC3hXVMYk.MJS)\Y(nKTtLKtZ!]Ek\-Zf$"&r;s(%#'iU_8/Foq[cqqPmg.h169@&<;/c(@qgH2/,kKCQ5=Ig)E7"ge4mZ>[Vc-*SBJ+[^JZHYGhRr/oVj+C5^-GdO;CBL2Q;UL6mhsB6&,T*`=?Jm>*r=a`dQ#NY7dU[H9g=D<@keA(.XhY]GEM_V`GlubcD(n1G,Sr'@Yag2pusAc9Ao6m2:T-\Gk0DKEh$N\P*j_Ihsp;d7P^FP8I06a+26#k!j@6&<X5aHY9c;WduiVampH8<5u%Q'l8pm-#?,eVaUSMuA7,X0Ht8$DL>tUToMN9i^,$9Q#C95cFq+-<rh[*]qZga5!nUY2(B~>endstream
Gatm<>Bed\&:Vs/R)cS5NFhpS53:40GIMpfVEF!0Ym""j$S0Drm'#</N/aN]_7PI)*&7"CUMiWM2\9rt7EfD-"5HX[nf%Vh_K6"#ZTX\)L(Dak^G!ZSVB=NP9*6!I+L6F\Hh\SL*FkE,$Uc*XBEZd(!F:B,J?Gs3/j1.;b\!\N^krhhZ;SIg-$#kB4C.Me/dfn#.-=<DhSAbF9],+H^>`oA7c<#H%O*!Hd-lZs4-<0a$9!kG-]GhNI5gkU%]n%IK]!$**CV4X$f@,bln)]3f6U%#m@.Q(3"L2If+3/s+>.e_;fJrs`=VK)Cj:/R@6K.f4[ug5'&<O!_t-j5jMsOjM&Tn\EARmqpHg:E1.Z[;Q<^R&3l,@ZJJ6Bb:-LD#ncI"m+?K1o9jMtm&uMMB"giE]j._U0pVYss@''lb-<m'+8iC/_K2bC5.(I#4:=0>#SeY^\QB8CP24U6M%X]cFG0jf6KS.o$rih.XI@Lap=')EGiSi5fJlaVO-'m7n(L7qKGk/!3N&6K45crt'1lQAbSX>/,aFru6rj7!;,)*uDX11Sl3n+Nj=sr]4),:T0Gj_j=M`>?[AqYq[TgW&A+`h5gi^rq`iJ'<-3P*c@_"[R24*!^7-a<LHq!l5piO-??o?Ak@UC=jSK@BkVg%sCTbj3O+%tf(Ac[_eAJG3^&J>:[\\"IF#R^f.b]JatdcBIS6:"H\^)g#[a_hN#FmKRIuf:CE5H7/4!^<l?ie/q6AcM&uG+)g>d#cc(;WiC,=*n<.;T)rr)@LdlB.G4SN<65No)8$=L!f=keBqe#dK<%S_WsYI"`L\kl]`VDL*4)dbYU'sNrq/Fk-!?f0$?GjRP\[L@9BBV8R<EBg/B5qlOd^fFk&0!>_4f\<o-BuI_]qt"4Qf.J_L]9h\V*sc92pR5V'H_.3kSt6(jqD:@d(?lIpSe=2k1gilf\o638sFbKH]-;:m;=6Rk3#LiTTV!,cqlN8/e5\:X0bQ(*"f_%O[Js:pO+Tf3QD!5jKQfp@U3m"b3WmEXh!Ia7&gJj1&,h]T@1\78fPq)-c'oMt.U*YBb)]`<]pbiZFaYKpaHUT"Gc]4AZgHl5`'=*%ci8Th<G!hX(*NX[RueHI"/J$T?&ZQjJVL)`9V!CF7F87kf[5l>#YmWiVe%q4Jt6c(pS-XV>I)FnWm.^1:BKja4%FfF4+o/JSqVq*_>`@H@j0[d$#['G!a@+:tl=(dc9([l8M5]aNk&i8Y\fVpqbCYnUY+5$5!p*-N"@X4P.A#?`\%$>D!b:FqhjUJ:D^==q-/E_j#._gVRfbmH0?I(ZM9r=RjX(_#F#HQ/d^>e7Ceg;5/V)l:A'-pF*3]gDagierGWnJp:,Icugn^2KG^IHhlffJ2?L7\^%b0YJ@[Q:0$o3b"JP&ZGGOL>@6$&u6^+U.DG+T6J>P3JcsXbDAE(-7mD1@87km6o)0oN`iCD%]NUDb0NM!LC"rOHBY5q_O4BLS>eB,f77.BG9%i.a;shSW'u&.RkomQnGq+;DiNql4FtWeiAn?-arSSdeet"+SaUYX_d&3e]ou].;VQ'G[Ac:[=-TjOX/WhqRPYY9Um"X4`>S5MSjMJ)oih>k7Cj`C!uPWi"KJWGL*VU'TXiG"$6"&,[3\pP'brr.+DaFWY!r@h6b:$85G@Af#5uuYJZUhVpi6Tc?6$I@o:BPVTs"AjQ;6lU[1;FY@=!/r?auN6^G5'WToSA?E7>e&X7Qf[C(:/CYNg%p9Y/bbdS5os[[Kn<2OW'!Vq,sBm9gSI%7rq%Z+#A:hR&2[nLpgIF2e~>endstream
endobj
xref
0 11
@@ -77,11 +77,11 @@ xref
0000000796 00000 n
0000001102 00000 n
0000001167 00000 n
0000004095 00000 n
0000004034 00000 n
trailer
<<
/ID
[<8697b2018a3d689f70795c513a06ba7d><8697b2018a3d689f70795c513a06ba7d>]
[<a4799bc865b1d3f8e1198486b0b00e10><a4799bc865b1d3f8e1198486b0b00e10>]
% ReportLab generated PDF document -- digest (opensource)
/Info 7 0 R
@@ -89,5 +89,5 @@ trailer
/Size 11
>>
startxref
5356
5916
%%EOF

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Build tmp/GRAFANA_vs_others_graphvis_pivot.pdf decision memo."""
"""Build docs/GRAFANA_vs_others_graphvis_pivot.pdf decision memo."""
from __future__ import annotations
@@ -13,16 +13,26 @@ from reportlab.lib.units import cm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
ROOT = Path(__file__).resolve().parents[2]
OUT_PDF = ROOT / "tmp" / "GRAFANA_vs_others_graphvis_pivot.pdf"
SOURCE_MD = ROOT / "tmp" / "GRAFANA_vs_others_graphvis_pivot.md"
OUT_PDF = ROOT / "docs" / "GRAFANA_vs_others_graphvis_pivot.pdf"
SOURCE_MD = ROOT / "docs" / "GRAFANA_vs_others_graphvis_pivot.md"
def p(text: str, style) -> Paragraph:
return Paragraph(text.replace("\n", "<br/>"), style)
def _cell(text: str, style: ParagraphStyle) -> Paragraph:
safe = (text or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
return Paragraph(safe.replace("\n", "<br/>"), style)
def add_table(story, headers: list[str], rows: list[list[str]], col_widths) -> None:
data = [headers] + rows
styles = getSampleStyleSheet()
head = ParagraphStyle("TblHead", parent=styles["Normal"], fontName="Helvetica-Bold", fontSize=9, leading=11)
body = ParagraphStyle("TblBody", parent=styles["Normal"], fontSize=9, leading=11)
data = [[_cell(h, head) for h in headers]]
for row in rows:
data.append([_cell(c, body) for c in row])
t = Table(data, colWidths=col_widths)
t.setStyle(
TableStyle(
@@ -33,6 +43,7 @@ def add_table(story, headers: list[str], rows: list[list[str]], col_widths) -> N
("FONTSIZE", (0, 0), (-1, -1), 9),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("WORDWRAP", (0, 0), (-1, -1), "CJK"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
@@ -71,7 +82,7 @@ def build_pdf() -> None:
["Context", "apps.f0xx.org + BE Alpine (nginx + php-fpm + MariaDB)"],
["Constraint", "No cron ETL/data copy/pipeline complexity"],
["Output", "Decision memo: Grafana vs custom PHP dashboards"],
["Markdown source", str(SOURCE_MD.relative_to(ROOT))],
["Markdown source", "docs/GRAFANA_vs_others_graphvis_pivot.md"],
],
[4.2 * cm, 12.2 * cm],
)

View File

@@ -11,8 +11,15 @@
})();
</script>
<link rel="stylesheet" href="/app/androidcast_project/crashes/assets/css/app.css">
<link rel="stylesheet" href="hub.css?v=20260601earth3d">
<link rel="stylesheet" href="hub-logo-layers.css?v=20260601earth3d">
<link rel="stylesheet" href="hub.css?v=20260532globe">
<link rel="stylesheet" href="hub-logo-layers.css?v=20260532globe">
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"
}
}
</script>
<script src="assets/analytics.config.js"></script>
<script src="/app/androidcast_project/crashes/assets/js/analytics.js" defer></script>
</head>
@@ -94,6 +101,12 @@
</span>
<span class="hub-card-label">AndroidCast crashes</span>
</a>
<a class="hub-card card card--lift" href="graphs/">
<span class="hub-card-icon" aria-hidden="true">
<span class="nav-icon nav-icon--graphs"></span>
</span>
<span class="hub-card-label">AndroidCast graphs</span>
</a>
<a class="hub-card card card--lift" href="build/">
<span class="hub-card-icon" aria-hidden="true">
<span class="nav-icon nav-icon--builder"></span>
@@ -158,7 +171,7 @@
var supportsLayout = !!(window.CSS && CSS.supports && CSS.supports('object-fit', 'cover'));
var logoEnabled = !!(stage && supportsSvg && supportsLayout);
var logoBuild = '20260601earth3d';
var logoBuild = '20260532globe';
function hubAsset(rel) {
var link = document.querySelector('link[href*="hub.css"]');
@@ -225,11 +238,11 @@
}
var useGlobe3d = logoEnabled && (params.get('globe3d') || '1') !== '0';
var globe3dSkip = { globe: true, continents: true, gridOverlay: true };
var globe3dLayers = { globe: true, continents: true, gridOverlay: true };
function appendLogoLayers() {
function appendStaticLayers() {
list.forEach(function (name) {
if (useGlobe3d && globe3dSkip[name]) return;
if (useGlobe3d && globe3dLayers[name]) return;
var img = document.createElement('img');
img.className = 'hub-logo-layer hub-logo-layer--' + name;
img.src = fragmentSrc(name);
@@ -247,7 +260,7 @@
}
if (logoEnabled) {
appendLogoLayers();
appendStaticLayers();
var logoOpacity = params.get('logoOpacity');
if (logoOpacity) {
@@ -259,12 +272,35 @@
if (useGlobe3d) {
var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var globeYawDeg = parseFloat(params.get('globeYawDeg'));
var globeOpts = {
build: logoBuild,
reducedMotion: reducedMotion
};
if (isFinite(globeYawDeg)) {
globeOpts.initialRotationY = globeYawDeg * Math.PI / 180;
}
var globeModuleUrl = new URL('assets/hub-globe.js', window.location.href);
globeModuleUrl.searchParams.set('v', logoBuild);
import(globeModuleUrl.href).then(function (mod) {
return mod.mountHubGlobe(stage, { build: logoBuild, reducedMotion: reducedMotion });
return mod.mountHubGlobe(stage, globeOpts);
}).then(function (handle) {
if (handle) {
stage.dataset.globe3d = 'active';
}
if (!handle) {
['globe', 'continents', 'gridOverlay'].forEach(function (name) {
if (list.indexOf(name) === -1) return;
var img = document.createElement('img');
img.className = 'hub-logo-layer hub-logo-layer--' + name;
img.src = fragmentSrc(name);
img.alt = '';
img.decoding = 'async';
stage.appendChild(img);
});
}
}).catch(function (err) {
console.warn('HubGlobe: falling back to static layers', err);
console.warn('HubGlobe 3D unavailable, using flat layers', err);
['globe', 'continents', 'gridOverlay'].forEach(function (name) {
if (list.indexOf(name) === -1) return;
var img = document.createElement('img');

View File

@@ -211,6 +211,31 @@ MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashe
**Attachments (005):** `mysql -u root -p androidcast_crashes < sql/migrations/005_ticket_attachments.sql` — files + external links (Google URLs auto-labeled).
## Graphs dashboard (`/graphs`)
Stage-1 custom BE graphs are available at:
- `https://apps.f0xx.org/app/androidcast_project/graphs/`
- Uses the same auth/session, users, RBAC, and DB as crashes/tickets.
- Hub card: `AndroidCast graphs` (between crashes and builder).
APIs:
- `GET /app/androidcast_project/graphs/api/graphs.php?days=14` (dashboard data in user/slug_admin/platform scopes)
- `POST /app/androidcast_project/graphs/api/graph_upload.php` (ingest `graph_session` payloads)
Fake data helper scripts:
```bash
cd examples/crash_reporter/backend
php scripts/generate_fake_graph_sessions.php --count=240 --days=30
php scripts/upload_fake_graph_sessions.php --url=https://apps.f0xx.org/app/androidcast_project/graphs
```
Payload shape (`schema_version=1`, `ingest_kind=graph_session`) includes:
`session_id`, `company_id`, `user_id`, `started_at_epoch_ms`, `ended_at_epoch_ms`, `duration_s`,
`direction`, `transport`, `avg_kbps`, `packet_loss_pct`, `ntp_source`, `install_source`, `meta`.
## Default accounts
| User | Password | Role |

View File

@@ -18,6 +18,10 @@ server {
return 301 /app/androidcast_project/crashes/;
}
location = /app/androidcast_project/graphs {
return 301 /app/androidcast_project/graphs/;
}
location ^~ /app/androidcast_project/crashes/assets/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
}
@@ -40,6 +44,32 @@ server {
client_max_body_size 4m;
}
location = /app/androidcast_project/graphs/api/graphs.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
fastcgi_param REQUEST_URI $request_uri;
}
location = /app/androidcast_project/graphs/api/graph_upload.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location ^~ /app/androidcast_project/graphs/ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location = /app/androidcast_project/build {
return 301 /app/androidcast_project/build/;
}

View File

@@ -5,6 +5,10 @@
return 301 /app/androidcast_project/crashes/;
}
location = /app/androidcast_project/graphs {
return 301 /app/androidcast_project/graphs/;
}
location = /app/androidcast_project {
return 301 /app/androidcast_project/;
}
@@ -68,3 +72,29 @@
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location = /app/androidcast_project/graphs/api/graphs.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
fastcgi_param REQUEST_URI $request_uri;
}
location = /app/androidcast_project/graphs/api/graph_upload.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location ^~ /app/androidcast_project/graphs/ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}

View File

@@ -22,6 +22,10 @@ server {
return 301 /app/androidcast_project/crashes/;
}
location = /app/androidcast_project/graphs {
return 301 /app/androidcast_project/graphs/;
}
location = /app/androidcast_project {
return 301 /app/androidcast_project/;
}
@@ -53,6 +57,32 @@ server {
client_max_body_size 4m;
}
location = /app/androidcast_project/graphs/api/graphs.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
fastcgi_param REQUEST_URI $request_uri;
}
location = /app/androidcast_project/graphs/api/graph_upload.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location ^~ /app/androidcast_project/graphs/ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location ~ /\. {
deny all;
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
json_out(['ok' => false, 'error' => 'empty body'], 400);
}
$data = json_decode($raw, true);
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid json'], 400);
}
if ((int) ($data['schema_version'] ?? 0) !== 1) {
json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
}
if (($data['ingest_kind'] ?? 'graph_session') !== 'graph_session') {
json_out(['ok' => false, 'error' => 'ingest_kind must be graph_session'], 400);
}
try {
GraphRepository::insertSession($data);
json_out(['ok' => true, 'session_id' => $data['session_id'] ?? null]);
} catch (InvalidArgumentException $e) {
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
} catch (Throwable $e) {
error_log('graph upload: ' . $e->getMessage());
$out = ['ok' => false, 'error' => 'store_failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
Auth::check();
$days = (int) ($_GET['days'] ?? 14);
if ($days <= 0) {
$days = 14;
}
try {
$payload = GraphRepository::dashboardData($days);
json_out(['ok' => true, 'data' => $payload]);
} catch (Throwable $e) {
error_log('graphs api: ' . $e->getMessage());
$out = ['ok' => false, 'error' => 'graphs_load_failed'];
if (cfg('debug')) {
$out['hint'] = $e->getMessage();
}
json_out($out, 500);
}

View File

@@ -237,6 +237,29 @@ a:hover { text-decoration: underline; }
transform-origin: left bottom;
box-sizing: border-box;
}
.nav-icon--graphs {
width: 16px;
height: 16px;
border-left: 2px solid currentColor;
border-bottom: 2px solid currentColor;
}
.nav-icon--graphs::before,
.nav-icon--graphs::after {
content: "";
position: absolute;
bottom: 0;
width: 3px;
background: currentColor;
border-radius: 1px 1px 0 0;
}
.nav-icon--graphs::before {
left: 4px;
height: 8px;
}
.nav-icon--graphs::after {
left: 10px;
height: 12px;
}
.ticket-col-issue { min-width: 200px; max-width: 420px; }
.ticket-issue-title { font-weight: 600; }
.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; }
@@ -916,6 +939,14 @@ a:hover { text-decoration: underline; }
.exc { color: var(--danger); font-weight: 600; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
.card canvas {
width: 100%;
max-width: 100%;
height: 180px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface2);
}
.cards--lift .card--lift {
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
}

View File

@@ -0,0 +1,109 @@
(function () {
function basePath() {
return document.body.getAttribute('data-base-path') || '';
}
function drawLineChart(canvas, series, color) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = 'rgba(90,110,140,0.15)';
ctx.fillRect(0, 0, w, h);
const points = Array.isArray(series) ? series : [];
if (!points.length) return;
let maxY = 0;
points.forEach((p) => {
const y = Number(p.y || 0);
if (y > maxY) maxY = y;
});
if (maxY <= 0) maxY = 1;
const pad = 20;
const plotW = w - pad * 2;
const plotH = h - pad * 2;
ctx.strokeStyle = 'rgba(120,140,170,0.5)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = pad + (plotH * i) / 4;
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(w - pad, y);
ctx.stroke();
}
ctx.strokeStyle = color || '#3d8bfd';
ctx.lineWidth = 2;
ctx.beginPath();
points.forEach((p, idx) => {
const x = pad + (plotW * idx) / Math.max(1, points.length - 1);
const y = pad + plotH - (plotH * Number(p.y || 0)) / maxY;
if (idx === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
function renderBreakdown(el, rows) {
if (!el) return;
if (!Array.isArray(rows) || !rows.length) {
el.textContent = 'No data';
return;
}
el.innerHTML = rows
.map((r) => `<div><strong>${String(r.label || '')}</strong>: ${Number(r.value || 0)}</div>`)
.join('');
}
function bootGraphs() {
const app = document.getElementById('graphs-app');
if (!app) return;
const status = document.getElementById('graphs-status');
const daysSel = document.getElementById('graphs-days');
function load() {
const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
status.textContent = 'Loading...';
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true);
xhr.onload = function () {
let payload = null;
try {
payload = JSON.parse(xhr.responseText);
} catch {
status.textContent = 'Invalid response';
return;
}
if (!payload || !payload.ok) {
status.textContent = (payload && payload.error) || 'Failed';
return;
}
const data = payload.data || {};
drawLineChart(document.getElementById('graph-user-sessions'), data.user && data.user.sessions_per_day, '#3d8bfd');
drawLineChart(document.getElementById('graph-slug-sessions'), data.slug_admin && data.slug_admin.sessions_per_day, '#5eead4');
drawLineChart(document.getElementById('graph-platform-sessions'), data.platform_admin && data.platform_admin.sessions_per_day, '#f59e0b');
drawLineChart(document.getElementById('graph-bitrate'), data.platform_admin && data.platform_admin.avg_bitrate_kbps_per_day, '#34d399');
renderBreakdown(document.getElementById('graph-ntp-breakdown'), data.platform_admin && data.platform_admin.ntp_sources);
renderBreakdown(document.getElementById('graph-install-breakdown'), data.platform_admin && data.platform_admin.install_sources);
status.textContent = 'Loaded';
};
xhr.onerror = function () {
status.textContent = 'Network error';
};
xhr.send();
}
if (daysSel) {
daysSel.addEventListener('change', load);
}
load();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootGraphs);
} else {
bootGraphs();
}
})();

View File

@@ -85,6 +85,16 @@ if ($route === '/api/ticket_attachments.php' || str_ends_with($route, '/api/tick
exit;
}
if ($route === '/api/graphs.php' || str_ends_with($route, '/api/graphs.php') || str_ends_with($route, '/graphs/api/graphs.php')) {
require __DIR__ . '/api/graphs.php';
exit;
}
if ($route === '/api/graph_upload.php' || str_ends_with($route, '/api/graph_upload.php') || str_ends_with($route, '/graphs/api/graph_upload.php')) {
require __DIR__ . '/api/graph_upload.php';
exit;
}
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
require __DIR__ . '/api/ticket_attachment.php';
exit;
@@ -118,6 +128,11 @@ if ($route === '/login') {
exit;
}
if ($route === '/graphs' || $route === '/graphs/' || str_ends_with($route, '/app/androidcast_project/graphs') || str_ends_with($route, '/app/androidcast_project/graphs/')) {
$_GET['view'] = 'graphs';
$route = '/';
}
Auth::check();
$grouped = isset($_GET['group']) && $_GET['group'] === '1';
@@ -176,6 +191,7 @@ if ($view === 'ticket' && isset($_GET['id'])) {
$pageTitle = match ($view) {
'home' => 'Home',
'tickets' => 'Tickets',
'graphs' => 'Graphs',
'reports', 'report' => 'Crash reports',
default => 'Console',
};

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::']);
$count = max(10, (int) ($opts['count'] ?? 180));
$days = max(1, (int) ($opts['days'] ?? 14));
$out = $opts['out'] ?? (__DIR__ . '/../../../../tmp/fake_graph_sessions.ndjson');
$companyId = (int) ($opts['company'] ?? 1);
$userId = (int) ($opts['user'] ?? 1);
$nowMs = (int) round(microtime(true) * 1000);
$startFloorMs = $nowMs - ($days * 86400 * 1000);
$transports = ['wifi', 'cellular', 'ethernet'];
$installs = ['direct', 'play_store', 'mdm', 'partner'];
$ntp = ['device', 'backend', 'pool.ntp.org'];
$fh = fopen($out, 'wb');
if (!$fh) {
fwrite(STDERR, "Cannot write $out\n");
exit(1);
}
for ($i = 0; $i < $count; $i++) {
$start = random_int($startFloorMs, $nowMs - 120000);
$duration = random_int(120, 9000);
$end = $start + ($duration * 1000);
$payload = [
'schema_version' => 1,
'ingest_kind' => 'graph_session',
'session_id' => 'fake-' . gmdate('Ymd', (int) ($start / 1000)) . '-' . substr(sha1((string) ($start . ':' . $i)), 0, 12),
'company_id' => $companyId,
'user_id' => $userId,
'started_at_epoch_ms' => $start,
'ended_at_epoch_ms' => $end,
'duration_s' => $duration,
'direction' => ($i % 3 === 0) ? 'receiver' : 'sender',
'transport' => $transports[array_rand($transports)],
'avg_kbps' => random_int(640, 12000),
'packet_loss_pct' => round(mt_rand(0, 600) / 100, 2),
'ntp_source' => $ntp[array_rand($ntp)],
'install_source' => $installs[array_rand($installs)],
'meta' => ['fake' => true, 'seed' => $i],
];
fwrite($fh, json_encode($payload, JSON_UNESCAPED_SLASHES) . "\n");
}
fclose($fh);
echo "Generated $count rows into $out\n";

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
$opts = getopt('', ['file::', 'url::', 'dry-run']);
$file = $opts['file'] ?? (__DIR__ . '/../../../../tmp/fake_graph_sessions.ndjson');
$base = rtrim($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/graphs', '/');
$dryRun = isset($opts['dry-run']);
$api = $base . '/api/graph_upload.php';
if (!is_readable($file)) {
fwrite(STDERR, "File not found: $file\n");
exit(1);
}
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$ok = 0;
$fail = 0;
foreach ($lines as $i => $line) {
$payload = json_decode($line, true);
if (!is_array($payload)) {
$fail++;
continue;
}
if ($dryRun) {
$ok++;
continue;
}
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
'ignore_errors' => true,
'timeout' => 20,
],
]);
$resp = @file_get_contents($api, false, $ctx);
$code = 0;
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
$code = (int) $m[1];
}
if ($code >= 200 && $code < 300) {
$ok++;
} else {
$fail++;
fwrite(STDERR, "FAIL line " . ($i + 1) . " http=$code resp=$resp\n");
}
}
echo "Done: ok=$ok fail=$fail total=" . count($lines) . "\n";
exit($fail > 0 ? 1 : 0);

View File

@@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
final class GraphRepository {
public static function ensureSchema(): void {
$pdo = Database::pdo();
if (Database::tableExists($pdo, 'graph_sessions')) {
return;
}
if (Database::isMysql()) {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS graph_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
session_id VARCHAR(96) NOT NULL,
company_id INT UNSIGNED NOT NULL DEFAULT 1,
user_id INT UNSIGNED NULL,
started_at_ms BIGINT NOT NULL,
ended_at_ms BIGINT NOT NULL,
duration_s INT NOT NULL,
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
avg_kbps INT NOT NULL DEFAULT 0,
packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
meta_json LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_graph_session_id (session_id),
KEY idx_graph_company_time (company_id, started_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
return;
}
$pdo->exec(
"CREATE TABLE IF NOT EXISTS graph_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE,
company_id INTEGER NOT NULL DEFAULT 1,
user_id INTEGER NULL,
started_at_ms INTEGER NOT NULL,
ended_at_ms INTEGER NOT NULL,
duration_s INTEGER NOT NULL,
direction TEXT NOT NULL DEFAULT 'sender',
transport TEXT NOT NULL DEFAULT 'wifi',
avg_kbps INTEGER NOT NULL DEFAULT 0,
packet_loss_pct REAL NOT NULL DEFAULT 0.0,
ntp_source TEXT NOT NULL DEFAULT 'device',
install_source TEXT NOT NULL DEFAULT 'direct',
meta_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)"
);
}
public static function insertSession(array $in): void {
self::ensureSchema();
$pdo = Database::pdo();
$sessionId = trim((string) ($in['session_id'] ?? ''));
if ($sessionId === '') {
throw new InvalidArgumentException('session_id is required');
}
$start = (int) ($in['started_at_epoch_ms'] ?? 0);
$end = (int) ($in['ended_at_epoch_ms'] ?? 0);
if ($start <= 0 || $end <= 0 || $end < $start) {
throw new InvalidArgumentException('invalid session timing');
}
$dir = strtolower(trim((string) ($in['direction'] ?? 'sender')));
if ($dir !== 'sender' && $dir !== 'receiver') {
$dir = 'sender';
}
$transport = strtolower(trim((string) ($in['transport'] ?? 'wifi')));
if ($transport === '') {
$transport = 'wifi';
}
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? 'device')));
if ($ntp === '') {
$ntp = 'device';
}
$install = strtolower(trim((string) ($in['install_source'] ?? 'direct')));
if ($install === '') {
$install = 'direct';
}
$duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000)));
$avgKbps = max(0, (int) ($in['avg_kbps'] ?? 0));
$loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0));
$companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId());
if ($companyId <= 0) {
$companyId = Rbac::defaultCompanyId();
}
$userId = isset($in['user_id']) ? (int) $in['user_id'] : null;
if ($userId !== null && $userId <= 0) {
$userId = null;
}
$meta = $in['meta'] ?? null;
$metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
if (Database::isMysql()) {
$sql = 'INSERT INTO graph_sessions
(session_id, company_id, user_id, started_at_ms, ended_at_ms, duration_s, direction, transport, avg_kbps, packet_loss_pct, ntp_source, install_source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
ended_at_ms = VALUES(ended_at_ms),
duration_s = VALUES(duration_s),
avg_kbps = VALUES(avg_kbps),
packet_loss_pct = VALUES(packet_loss_pct),
ntp_source = VALUES(ntp_source),
install_source = VALUES(install_source),
meta_json = VALUES(meta_json)';
} else {
$sql = 'INSERT OR REPLACE INTO graph_sessions
(session_id, company_id, user_id, started_at_ms, ended_at_ms, duration_s, direction, transport, avg_kbps, packet_loss_pct, ntp_source, install_source, meta_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([
$sessionId,
$companyId,
$userId,
$start,
$end,
$duration,
$dir,
$transport,
$avgKbps,
$loss,
$ntp,
$install,
$metaJson,
]);
}
public static function dashboardData(int $days = 14): array {
self::ensureSchema();
$days = max(3, min(60, $days));
$user = Auth::user();
$uid = (int) ($user['id'] ?? 0);
$scopeUserId = $uid > 0 ? $uid : null;
$activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
return [
'window_days' => $days,
'user' => self::buildScope($days, $activeCompanyId, $scopeUserId),
'slug_admin' => self::buildScope($days, $activeCompanyId, null),
'platform_admin' => self::buildScope($days, null, null),
];
}
private static function buildScope(int $days, ?int $companyId, ?int $userId): array {
$startMs = (int) ((time() - ($days * 86400)) * 1000);
return [
'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId),
'crashes_per_day' => self::crashesPerDay($startMs, $companyId),
'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId),
'ntp_sources' => self::breakdown('ntp_source', $startMs, $companyId, $userId),
'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId),
'transport_mix' => self::breakdown('transport', $startMs, $companyId, $userId),
];
}
private static function sessionsPerDay(int $startMs, ?int $companyId, ?int $userId): array {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT started_at_ms FROM graph_sessions WHERE ' . implode(' AND ', $where) . ' ORDER BY started_at_ms ASC';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$bucket = [];
foreach ($rows as $row) {
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000);
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
}
return self::assocToSeries($bucket);
}
private static function crashesPerDay(int $startMs, ?int $companyId): array {
$where = ['received_at_ms >= ?'];
$params = [$startMs];
if ($companyId !== null && $companyId > 0) {
$where[] = 'company_id = ?';
$params[] = $companyId;
}
$sql = 'SELECT received_at_ms FROM reports WHERE ' . implode(' AND ', $where) . ' ORDER BY received_at_ms ASC';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$bucket = [];
foreach ($rows as $row) {
$day = gmdate('Y-m-d', ((int) ($row['received_at_ms'] ?? 0)) / 1000);
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
}
return self::assocToSeries($bucket);
}
private static function avgBitratePerDay(int $startMs, ?int $companyId, ?int $userId): array {
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT started_at_ms, avg_kbps FROM graph_sessions WHERE ' . implode(' AND ', $where) . ' ORDER BY started_at_ms ASC';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$sum = [];
$cnt = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000);
$sum[$day] = ($sum[$day] ?? 0.0) + (float) ($row['avg_kbps'] ?? 0);
$cnt[$day] = ($cnt[$day] ?? 0) + 1;
}
$avg = [];
foreach ($sum as $day => $total) {
$avg[$day] = $cnt[$day] > 0 ? round($total / $cnt[$day], 1) : 0.0;
}
return self::assocToSeries($avg);
}
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
$allowed = ['ntp_source', 'install_source', 'transport'];
if (!in_array($column, $allowed, true)) {
return [];
}
$where = ['started_at_ms >= ?'];
$params = [$startMs];
self::appendScope($where, $params, $companyId, $userId);
$sql = 'SELECT ' . $column . ' AS k, COUNT(*) AS c FROM graph_sessions WHERE '
. implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC';
$stmt = Database::pdo()->prepare($sql);
$stmt->execute($params);
$out = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
}
return $out;
}
private static function appendScope(array &$where, array &$params, ?int $companyId, ?int $userId): void {
if ($companyId !== null && $companyId > 0) {
$where[] = 'company_id = ?';
$params[] = $companyId;
}
if ($userId !== null && $userId > 0) {
$where[] = 'user_id = ?';
$params[] = $userId;
}
}
private static function assocToSeries(array $assoc): array {
ksort($assoc);
$out = [];
foreach ($assoc as $k => $v) {
$out[] = ['x' => $k, 'y' => $v];
}
return $out;
}
}

View File

@@ -43,6 +43,7 @@ require_once __DIR__ . '/TicketAttachmentRepository.php';
require_once __DIR__ . '/TicketCommentRepository.php';
require_once __DIR__ . '/UserRepository.php';
require_once __DIR__ . '/TicketRepository.php';
require_once __DIR__ . '/GraphRepository.php';
require_once __DIR__ . '/AnalyticsHead.php';
function cfg(string $key, $default = null) {

View File

@@ -31,6 +31,9 @@
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
<?php endif; ?>
<?php if (($view ?? '') === 'graphs'): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
<?php endif; ?>
<?php AnalyticsHead::render('crashes'); ?>
</head>
<body data-base-path="<?= h(Auth::basePath()) ?>"
@@ -75,6 +78,15 @@
<span class="nav-label" data-i18n="nav.tickets">Tickets</span>
</a>
</li>
<li>
<a href="/app/androidcast_project/graphs/"
class="nav-link <?= ($view ?? '') === 'graphs' ? 'active' : '' ?>"
aria-label="Graphs"
title="Graphs">
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
<span class="nav-label">Graphs</span>
</a>
</li>
<li>
<a href="/app/androidcast_project/build/"
class="nav-link"
@@ -222,6 +234,60 @@
</div>
<nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav>
</div>
<?php elseif (($view ?? '') === 'graphs'): ?>
<div id="graphs-app" class="reports-app">
<div class="toolbar reports-toolbar">
<h1>AndroidCast graphs</h1>
<div class="toolbar-actions">
<label class="toolbar-select">
<span>Theme</span>
<select id="theme-select" aria-label="UI theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
<label class="toolbar-select">
<span>Window</span>
<select id="graphs-days" aria-label="Graphs window">
<option value="7">7d</option>
<option value="14" selected>14d</option>
<option value="30">30d</option>
</select>
</label>
</div>
</div>
<p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
<div class="cards cards--lift">
<article class="card card--lift">
<h3>User scope</h3>
<canvas id="graph-user-sessions" width="560" height="180"></canvas>
</article>
<article class="card card--lift">
<h3>Slug-admin scope</h3>
<canvas id="graph-slug-sessions" width="560" height="180"></canvas>
</article>
<article class="card card--lift">
<h3>Platform scope</h3>
<canvas id="graph-platform-sessions" width="560" height="180"></canvas>
</article>
<article class="card card--lift">
<h3>Bitrate (kbps)</h3>
<canvas id="graph-bitrate" width="560" height="180"></canvas>
</article>
<article class="card card--lift">
<h3>NTP source mix</h3>
<div id="graph-ntp-breakdown" class="muted">—</div>
</article>
<article class="card card--lift">
<h3>Install source mix</h3>
<div id="graph-install-breakdown" class="muted">—</div>
</article>
</div>
<p class="muted">
APIs: <code><?= h(Auth::basePath()) ?>/api/graphs.php</code> and
<code><?= h(Auth::basePath()) ?>/api/graph_upload.php</code>
</p>
</div>
<?php endif; ?>
</main>
</div>

View File

@@ -4,4 +4,4 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
python3 "$ROOT_DIR/docs/_pdf_build/build_graphvis_pivot_pdf.py"
echo "Generated: $ROOT_DIR/tmp/GRAFANA_vs_others_graphvis_pivot.pdf"
echo "Generated: $ROOT_DIR/docs/GRAFANA_vs_others_graphvis_pivot.pdf"